package product import ( "context" "errors" "time" "perms-system-server/internal/consts" "perms-system-server/internal/loaders" authHelper "perms-system-server/internal/logic/auth" productModel "perms-system-server/internal/model/product" "perms-system-server/internal/response" "perms-system-server/internal/svc" "perms-system-server/internal/types" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/sqlx" ) type UpdateProductLogic struct { logx.Logger ctx context.Context svcCtx *svc.ServiceContext } func NewUpdateProductLogic(ctx context.Context, svcCtx *svc.ServiceContext) *UpdateProductLogic { return &UpdateProductLogic{ Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx, } } // UpdateProduct 更新产品信息。仅超管可调用,可修改产品名称、备注和启用/禁用状态。禁用产品后其成员将无法访问。 // // 审计 L-R15-3(产品禁用强制吊销成员会话): // 当请求构成"Enabled → Disabled"的状态迁移时,事务内除了 UpdateWithTx 产品行之外,还会对该产品下 // 所有启用成员的 sys_user.tokenVersion 做一次 +1,让旧 access token 在下一次 middleware 校验时 // 因 `claims.TokenVersion != ud.TokenVersion` 被 401 拒绝(方案 A)。与 UpdateMember 的降权吊销 // 口径对齐:不依赖 UserDetailsLoader.CleanByProduct 的 post-commit 成功(Redis 抖动时窗口可达 // 5min TTL),而是直接在**签发层**把旧令牌打无效。批量 UPDATE 的数据量是"本产品启用成员数", // 量级 ≤ 几千,一条 SQL 即可,不触碰全局用户。 func (l *UpdateProductLogic) UpdateProduct(req *types.UpdateProductReq) error { if err := authHelper.RequireSuperAdmin(l.ctx); err != nil { return err } if len(req.Name) > 64 { return response.ErrBadRequest("产品名称长度不能超过64个字符") } if len(req.Remark) > 255 { return response.ErrBadRequest("备注长度不能超过255个字符") } product, err := l.svcCtx.SysProductModel.FindOne(l.ctx, req.Id) if err != nil { return response.ErrNotFound("产品不存在") } if req.Status != 0 && req.Status != consts.StatusEnabled && req.Status != consts.StatusDisabled { return response.ErrBadRequest("状态值无效,仅支持 1(启用) 和 2(禁用)") } nextStatus := product.Status if req.Status != 0 { nextStatus = req.Status } // 审计 L-R15-3:只有"Enabled → Disabled"才触发批量吊销。Disabled → Enabled(重启用) // 不需要递增——重启用不会让任何用户获得未曾持有的权限,旧 session 继续生效属于预期行为。 shouldRevokeSessions := product.Status == consts.StatusEnabled && nextStatus == consts.StatusDisabled prevUpdateTime := product.UpdateTime product.Name = req.Name product.Remark = req.Remark product.Status = nextStatus product.UpdateTime = time.Now().Unix() var revokedUserIds []int64 if err := l.svcCtx.SysProductModel.TransactCtx(l.ctx, func(ctx context.Context, session sqlx.Session) error { // 审计 L-R15-3:把产品行的乐观锁更新 + 成员 userId 读取 + 批量 tokenVersion 递增收敛到 // 同一事务里——任意一步失败事务整体 rollback,不会出现"产品被禁但成员 token 没吊销"或 // "成员 token 被吊销但产品状态没改"的脏中间态。UpdateWithOptLockTx 的 WHERE updateTime=? // 复现了原 UpdateWithOptLock 的 CAS 语义,rowsAffected=0 → ErrUpdateConflict。 if err := l.svcCtx.SysProductModel.UpdateWithOptLockTx(ctx, session, product, prevUpdateTime); err != nil { return err } if shouldRevokeSessions { ids, err := l.svcCtx.SysProductMemberModel.FindActiveMemberUserIdsByProductCodeTx(ctx, session, product.Code) if err != nil { return err } if len(ids) > 0 { if err := l.svcCtx.SysUserModel.BatchIncrementTokenVersionWithTx(ctx, session, ids); err != nil { return err } revokedUserIds = ids } } return nil }); err != nil { if errors.Is(err, productModel.ErrUpdateConflict) { return response.ErrConflict("数据已被其他操作修改,请刷新后重试") } return err } // 审计 L-R13-5 方案 A:产品禁用直接让 loadPerms 清空 Perms,UD 失效不能随请求断连丢失。 cleanCtx, cancel := loaders.DetachCacheCleanCtx(l.ctx) defer cancel() // 审计 L-R12-1:UpdateWithOptLockTx 不再内嵌 sqlc cache 失效,需要调用方在 tx 成功后走 // InvalidateProductCache 把 sysProduct 低层缓存(id/appKey/code)三把键一并失效。 l.svcCtx.SysProductModel.InvalidateProductCache(cleanCtx, product.Id, product.AppKey, product.Code) l.svcCtx.UserDetailsLoader.CleanByProduct(cleanCtx, product.Code) // 审计 L-R15-3:sys_user.tokenVersion 已在 tx 内被批量 +1;post-commit 必须把对应的 // sysUser 低层缓存(cacheSysUserIdPrefix / cacheSysUserUsernamePrefix)一起失效,否则 // UD loader 下次 cache-miss 重建时会从 sysUser 低层缓存里读到旧 tokenVersion,把刚递增 // 过的值抹回去。FindByIds 单次 IN(...) 查询,N 量级为产品成员数,通常数千内可控。 if len(revokedUserIds) > 0 { users, err := l.svcCtx.SysUserModel.FindByIds(cleanCtx, revokedUserIds) if err != nil { logx.WithContext(l.ctx).Errorf("UpdateProduct post-commit FindByIds failed, tokenVersion bump succeeded but sysUser caches may stay stale until TTL: productCode=%s count=%d err=%v", product.Code, len(revokedUserIds), err) } else { for _, u := range users { l.svcCtx.SysUserModel.InvalidateProfileCache(cleanCtx, u.Id, u.Username) } } } return nil }