Files
qian 6683ca6d40 fix(cache): 修复高并发下租户模型权限缓存缺失导致误判问题
- CheckTenantModelAccess 增加 L1/L2 双层缓存(TenantModelAccessCache,TTL 300s)
  区分「数据库无记录(IsNil=true,穿透到分组权限)」和「有记录」两种状态,
  高并发下不再每次请求都发起 LEFT JOIN 查询,消除连接池压满时的误判根因

- 修复 Cache.DeleteByPattern 遗漏 L1 gcache 清除和跨实例 pub/sub 通知的问题:
  原实现只做 Redis SCAN+批量 DEL,L1 条目靠 TTL 自然过期;
  改为 SCAN 收集所有 key → 批量 DEL L2 → 逐 key gcache.Remove + PUBLISH cache:invalidate,
  与 Cache.Delete 的三层清除行为对齐

- 新增 ClearTenantModelAccessCache(ctx, tenantID),以 {tenantID}:* 模式清除该租户缓存

- admin/tenant_model.go:BatchAssignModels / UpdateTenantModel / DeleteTenantModel
  三个写操作在 ClearTenantPriceCache 后追加 ClearTenantModelAccessCache 调用

- admin/model_group.go:invalidateTenantGroupCache 新增 ClearTenantModelAccessCache 调用,
  覆盖所有分组写操作(SetGroupModels / SetTenantGroups / UpdateModelGroup status 变更)
2026-08-02 23:59:00 +08:00

418 lines
13 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package admin
import (
"context"
"fmt"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
v1 "github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/billing"
"github.com/qianfree/team-api/internal/logic/common"
relay "github.com/qianfree/team-api/internal/logic/relay"
do "github.com/qianfree/team-api/internal/model/do"
)
// ==================== 分组 CRUD ====================
// ListModelGroups 模型分组列表
func (s *sAdmin) ListModelGroups(ctx context.Context, req *v1.ModelGroupListReq) (*v1.ModelGroupListRes, error) {
query := g.DB().Model("mdl_model_groups mg").Ctx(ctx).
LeftJoin("(SELECT group_id, COUNT(*) AS model_count FROM mdl_group_models GROUP BY group_id) mc ON mc.group_id = mg.id").
LeftJoin("(SELECT group_id, COUNT(*) AS tenant_count FROM mdl_tenant_groups GROUP BY group_id) tc ON tc.group_id = mg.id")
if req.Status != "" {
query = query.Where("mg.status", req.Status)
}
if req.Search != "" {
query = query.Where("mg.name LIKE ? OR mg.code LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
countQuery := g.DB().Model("mdl_model_groups mg").Ctx(ctx)
if req.Status != "" {
countQuery = countQuery.Where("mg.status", req.Status)
}
if req.Search != "" {
countQuery = countQuery.Where("mg.name LIKE ? OR mg.code LIKE ?", "%"+req.Search+"%", "%"+req.Search+"%")
}
total, _ := countQuery.Count()
var results []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
Description string `json:"description"`
Status string `json:"status"`
IsDefault bool `json:"is_default"`
ModelCount int `json:"model_count"`
TenantCount int `json:"tenant_count"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
err := query.
Fields("mg.id, mg.name, mg.code, mg.description, mg.status, mg.is_default, COALESCE(mc.model_count, 0) AS model_count, COALESCE(tc.tenant_count, 0) AS tenant_count, mg.created_at, mg.updated_at").
OrderAsc("mg.id").
Page(req.Page, req.PageSize).
Scan(&results)
if err != nil {
return nil, err
}
list := make([]v1.ModelGroupItem, 0, len(results))
for _, r := range results {
list = append(list, v1.ModelGroupItem{
ID: r.ID,
Name: r.Name,
Code: r.Code,
Description: r.Description,
Status: r.Status,
IsDefault: r.IsDefault,
ModelCount: r.ModelCount,
TenantCount: r.TenantCount,
CreatedAt: r.CreatedAt,
UpdatedAt: r.UpdatedAt,
})
}
return &v1.ModelGroupListRes{
List: list,
Total: total,
Page: req.Page,
PageSize: req.PageSize,
}, nil
}
// CreateModelGroup 创建模型分组
func (s *sAdmin) CreateModelGroup(ctx context.Context, req *v1.ModelGroupCreateReq) (*v1.ModelGroupCreateRes, error) {
count, _ := dao.MdlModelGroups.Ctx(ctx).Where("code", req.Code).Count()
if count > 0 {
return nil, common.NewBusinessError(consts.CodeModelGroupCodeExists, consts.MsgModelGroupCodeExists)
}
result, err := dao.MdlModelGroups.Ctx(ctx).Insert(do.MdlModelGroups{
Name: req.Name,
Code: req.Code,
Description: req.Description,
IsDefault: req.IsDefault,
})
if err != nil {
return nil, err
}
id, _ := result.LastInsertId()
if len(req.ModelIds) > 0 {
for _, modelID := range req.ModelIds {
if _, err := dao.MdlGroupModels.Ctx(ctx).Insert(do.MdlGroupModels{
GroupId: id,
ModelId: modelID,
}); err != nil {
return nil, gerror.Wrapf(err, "insert group model association for model %d", modelID)
}
}
}
return &v1.ModelGroupCreateRes{ID: id}, nil
}
// UpdateModelGroup 更新模型分组
func (s *sAdmin) UpdateModelGroup(ctx context.Context, req *v1.ModelGroupUpdateReq) (*v1.ModelGroupUpdateRes, error) {
count, _ := dao.MdlModelGroups.Ctx(ctx).Where("id", req.ID).Count()
if count == 0 {
return nil, common.NewBusinessError(consts.CodeModelGroupNotFound, consts.MsgModelGroupNotFound)
}
data := do.MdlModelGroups{}
if req.Name != "" {
data.Name = req.Name
}
if req.Description != "" {
data.Description = req.Description
}
if req.Status != "" {
data.Status = req.Status
}
if req.IsDefault != nil {
data.IsDefault = *req.IsDefault
}
_, err := dao.MdlModelGroups.Ctx(ctx).Where("id", req.ID).Data(data).Update()
if err != nil {
return nil, err
}
if req.Status != "" {
invalidateTenantsInGroup(ctx, req.ID)
}
return nil, nil
}
// DeleteModelGroup 删除模型分组
func (s *sAdmin) DeleteModelGroup(ctx context.Context, req *v1.ModelGroupDeleteReq) (*v1.ModelGroupDeleteRes, error) {
count, _ := dao.MdlModelGroups.Ctx(ctx).Where("id", req.ID).Count()
if count == 0 {
return nil, common.NewBusinessError(consts.CodeModelGroupNotFound, consts.MsgModelGroupNotFound)
}
tenantCount, _ := dao.MdlTenantGroups.Ctx(ctx).Where("group_id", req.ID).Count()
if tenantCount > 0 {
return nil, common.NewBusinessError(consts.CodeModelGroupHasTenants, consts.MsgModelGroupHasTenants)
}
if _, err := dao.MdlGroupModels.Ctx(ctx).Where("group_id", req.ID).Delete(); err != nil {
return nil, gerror.Wrapf(err, "delete group models for group %d", req.ID)
}
_, err := dao.MdlModelGroups.Ctx(ctx).Where("id", req.ID).Delete()
if err != nil {
return nil, err
}
return nil, nil
}
// ListModelGroupOptions 分组选项列表(不分页,用于下拉选择)
func (s *sAdmin) ListModelGroupOptions(ctx context.Context, req *v1.ModelGroupOptionsReq) (*v1.ModelGroupOptionsRes, error) {
query := g.DB().Model("mdl_model_groups mg").Ctx(ctx).
LeftJoin("(SELECT group_id, COUNT(*) AS model_count FROM mdl_group_models GROUP BY group_id) mc ON mc.group_id = mg.id")
if req.Status != "" {
query = query.Where("mg.status", req.Status)
} else {
query = query.Where("mg.status", "active")
}
var results []struct {
ID int64 `json:"id"`
Name string `json:"name"`
Code string `json:"code"`
IsDefault bool `json:"is_default"`
ModelCount int `json:"model_count"`
}
err := query.
Fields("mg.id, mg.name, mg.code, mg.is_default, COALESCE(mc.model_count, 0) AS model_count").
OrderAsc("mg.id").
Scan(&results)
if err != nil {
return nil, err
}
list := make([]v1.ModelGroupOptionItem, 0, len(results))
for _, r := range results {
list = append(list, v1.ModelGroupOptionItem{
ID: r.ID,
Name: r.Name,
Code: r.Code,
IsDefault: r.IsDefault,
ModelCount: r.ModelCount,
})
}
return &v1.ModelGroupOptionsRes{List: list}, nil
}
// ==================== 分组模型管理 ====================
// ListGroupModels 查看分组内模型列表
func (s *sAdmin) ListGroupModels(ctx context.Context, req *v1.GroupModelsListReq) (*v1.GroupModelsListRes, error) {
var results []struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
}
err := dao.MdlGroupModels.Ctx(ctx).As("gm").
InnerJoin("mdl_models m ON gm.model_id = m.id").
Where("gm.group_id", req.ID).
Fields("m.model_id, m.model_name, m.category, m.status").
OrderAsc("m.category").
OrderAsc("m.model_id").
Scan(&results)
if err != nil {
return nil, err
}
list := make([]v1.GroupModelItem, 0, len(results))
for _, r := range results {
list = append(list, v1.GroupModelItem{
ModelId: r.ModelId,
ModelName: r.ModelName,
Category: r.Category,
Status: r.Status,
})
}
return &v1.GroupModelsListRes{List: list}, nil
}
// SetGroupModels 设置分组内模型(全量替换)
func (s *sAdmin) SetGroupModels(ctx context.Context, req *v1.GroupModelsSetReq) (*v1.GroupModelsSetRes, error) {
count, _ := dao.MdlModelGroups.Ctx(ctx).Where("id", req.ID).Count()
if count == 0 {
return nil, common.NewBusinessError(consts.CodeModelGroupNotFound, consts.MsgModelGroupNotFound)
}
// 删-插必须在同一事务内:插入失败(DB 抖动/唯一键冲突)若与删除分离,会清空分组关联,
// 影响下游按分组计算的租户可用模型与定价。事务采用 ctx 传播式:闭包内统一 dao.Xxx.Ctx(ctx)。
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 删除旧关联
_, err := dao.MdlGroupModels.Ctx(ctx).Where("group_id", req.ID).Delete()
if err != nil {
return err
}
// 批量插入新关联
if len(req.ModelIds) > 0 {
insertData := make([]do.MdlGroupModels, 0, len(req.ModelIds))
for _, modelID := range req.ModelIds {
insertData = append(insertData, do.MdlGroupModels{
GroupId: req.ID,
ModelId: modelID,
})
}
_, err = dao.MdlGroupModels.Ctx(ctx).Batch(len(insertData)).Insert(insertData)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
invalidateTenantsInGroup(ctx, req.ID)
return nil, nil
}
// ==================== 租户分组管理 ====================
// ListTenantGroups 查看租户关联的分组列表
func (s *sAdmin) ListTenantGroups(ctx context.Context, req *v1.TenantGroupsListReq) (*v1.TenantGroupsListRes, error) {
var results []struct {
GroupID int64 `json:"group_id"`
Name string `json:"name"`
Code string `json:"code"`
Status string `json:"status"`
ModelCount int `json:"model_count"`
}
err := dao.MdlTenantGroups.Ctx(ctx).As("tg").
InnerJoin("mdl_model_groups g ON tg.group_id = g.id").
LeftJoin("(SELECT group_id, COUNT(*) AS model_count FROM mdl_group_models GROUP BY group_id) mc ON mc.group_id = g.id").
Where("tg.tenant_id", req.TenantID).
Fields("g.id AS group_id, g.name, g.code, g.status, COALESCE(mc.model_count, 0) AS model_count").
OrderAsc("g.id").
Scan(&results)
if err != nil {
return nil, err
}
list := make([]v1.TenantGroupItem, 0, len(results))
for _, r := range results {
list = append(list, v1.TenantGroupItem{
GroupID: r.GroupID,
Name: r.Name,
Code: r.Code,
Status: r.Status,
ModelCount: r.ModelCount,
})
}
return &v1.TenantGroupsListRes{List: list}, nil
}
// SetTenantGroups 设置租户关联的分组(全量替换)
func (s *sAdmin) SetTenantGroups(ctx context.Context, req *v1.TenantGroupsSetReq) (*v1.TenantGroupsSetRes, error) {
// 删-插必须在同一事务内:插入失败(DB 抖动/唯一键冲突)若与删除分离,会清空租户模型分组,
// 影响下游按分组计算的租户可用模型与定价。事务采用 ctx 传播式:闭包内统一 dao.Xxx.Ctx(ctx),
// 参照 tenant/member_model_scope.go 的正确写法。
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
// 删除旧关联
_, err := dao.MdlTenantGroups.Ctx(ctx).Where("tenant_id", req.TenantID).Delete()
if err != nil {
return err
}
// 批量插入新关联
if len(req.GroupIds) > 0 {
insertData := make([]do.MdlTenantGroups, 0, len(req.GroupIds))
for _, groupID := range req.GroupIds {
insertData = append(insertData, do.MdlTenantGroups{
TenantId: req.TenantID,
GroupId: groupID,
})
}
_, err = dao.MdlTenantGroups.Ctx(ctx).Batch(len(insertData)).Insert(insertData)
if err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
invalidateTenantGroupCache(ctx, req.TenantID)
billing.ClearTenantPriceCache(ctx, req.TenantID)
return nil, nil
}
// ==================== 缓存失效辅助函数 ====================
func invalidateTenantGroupCache(ctx context.Context, tenantID int64) {
common.TenantGroupModelCache.Delete(ctx, fmt.Sprintf("%d", tenantID))
// 同步清除租户模型访问权限缓存:分组变动会影响通过分组获得的模型权限
relay.ClearTenantModelAccessCache(ctx, tenantID)
}
func invalidateTenantsForModel(ctx context.Context, modelID int64) {
var groupIDs []struct {
GroupId int64 `json:"group_id"`
}
dao.MdlGroupModels.Ctx(ctx).
Where("model_id", modelID).
Fields("group_id").
Scan(&groupIDs)
for _, g := range groupIDs {
invalidateTenantsInGroup(ctx, g.GroupId)
}
// 同时清除直接分配了该模型的租户缓存(mdl_tenant_models)
var tenantIDs []struct {
TenantId int64 `json:"tenant_id"`
}
dao.MdlTenantModels.Ctx(ctx).
Where("model_id", modelID).
Fields("tenant_id").
Scan(&tenantIDs)
for _, t := range tenantIDs {
invalidateTenantGroupCache(ctx, t.TenantId)
}
}
func invalidateTenantsInGroup(ctx context.Context, groupID int64) {
var tenants []struct {
TenantId int64 `json:"tenant_id"`
}
dao.MdlTenantGroups.Ctx(ctx).
Where("group_id", groupID).
Fields("tenant_id").
Scan(&tenants)
for _, t := range tenants {
invalidateTenantGroupCache(ctx, t.TenantId)
}
}