package pub import ( "context" "errors" "time" "perms-system-server/internal/consts" permModel "perms-system-server/internal/model/perm" "perms-system-server/internal/svc" "perms-system-server/internal/util" "github.com/zeromicro/go-zero/core/logx" "github.com/zeromicro/go-zero/core/stores/sqlx" "golang.org/x/crypto/bcrypt" ) type SyncPermsResult struct { Added int64 Updated int64 Disabled int64 } type SyncPermItem struct { Code string Name string Remark string } type SyncPermsError struct { Code int Message string } func (e *SyncPermsError) Error() string { return e.Message } func ExecuteSyncPerms(ctx context.Context, svcCtx *svc.ServiceContext, appKey, appSecret string, perms []SyncPermItem) (*SyncPermsResult, error) { product, err := svcCtx.SysProductModel.FindOneByAppKey(ctx, appKey) if err != nil { return nil, &SyncPermsError{Code: 401, Message: "无效的appKey"} } if err := bcrypt.CompareHashAndPassword([]byte(product.AppSecret), []byte(appSecret)); err != nil { return nil, &SyncPermsError{Code: 401, Message: "appSecret验证失败"} } if product.Status != consts.StatusEnabled { return nil, &SyncPermsError{Code: 403, Message: "产品已被禁用"} } if len(perms) == 0 { return nil, &SyncPermsError{Code: 400, Message: "权限列表不能为空"} } // 去重请求列表,避免同一笔同步里 codes 互相冲突。 codes := make([]string, 0, len(perms)) seen := make(map[string]bool, len(perms)) dedupPerms := make([]SyncPermItem, 0, len(perms)) for _, item := range perms { if seen[item.Code] { continue } seen[item.Code] = true codes = append(codes, item.Code) dedupPerms = append(dedupPerms, item) } now := time.Now().Unix() var added, updated, disabled int64 // 同 tx 内先 SELECT ... FOR UPDATE 锁 sys_product 行,再在 tx 内读取 existing 并写入: // 把同一 product 的并发同步串行化,避免两次同步都认为 code X 不存在并并发 INSERT 撞 // sys_perm UNIQUE(productCode, code) 拿 1062(见审计 H-3)。 err = svcCtx.SysPermModel.TransactCtx(ctx, func(txCtx context.Context, session sqlx.Session) error { locked, err := svcCtx.SysProductModel.LockByCodeTx(txCtx, session, product.Code) if err != nil { if errors.Is(err, sqlx.ErrNotFound) { return &SyncPermsError{Code: 404, Message: "产品不存在"} } return err } // 审计 M-R10-1:事务外的 FindOneByAppKey 只是 cache/stale 读,无法感知"UpdateProduct // 与本同步并发到达、UpdateProduct 后提交"的时序(SyncPerms 先拿到 X 锁时,UpdateProduct 的 // UPDATE 会排队等锁,SyncPerms 在锁内读到的 product.Status 仍是 Enabled 但真正提交顺序会 // 让 product 行最终是 Disabled)。LockByCodeTx 已经拿到行 X 锁,这里对 locked.Status 再做一 // 次事务内复核,任何已禁用的产品都不得继续写 sys_perm,修复"禁用后同一秒仍生成 perm diff" // 把审计告警带偏的问题。安全侧 loadPerms 仍会按 ProductStatus!=Enabled 返空,但运营/审计 // 链路需要本处兜底避免假象。 if locked.Status != consts.StatusEnabled { return &SyncPermsError{Code: 403, Message: "产品已被禁用"} } existingMap, err := svcCtx.SysPermModel.FindMapByProductCodeWithTx(txCtx, session, product.Code) if err != nil { return err } var toInsert []*permModel.SysPerm var toUpdate []*permModel.SysPerm for _, item := range dedupPerms { existing, ok := existingMap[item.Code] if !ok { toInsert = append(toInsert, &permModel.SysPerm{ ProductCode: product.Code, Name: item.Name, Code: item.Code, Remark: item.Remark, Status: consts.StatusEnabled, CreateTime: now, UpdateTime: now, }) added++ continue } if existing.Name != item.Name || existing.Remark != item.Remark || existing.Status != consts.StatusEnabled { existing.Name = item.Name existing.Remark = item.Remark existing.Status = consts.StatusEnabled existing.UpdateTime = now toUpdate = append(toUpdate, existing) updated++ } } if len(toInsert) > 0 { if insertErr := svcCtx.SysPermModel.BatchInsertWithTx(txCtx, session, toInsert); insertErr != nil { return insertErr } } if len(toUpdate) > 0 { if updateErr := svcCtx.SysPermModel.BatchUpdateWithTx(txCtx, session, toUpdate); updateErr != nil { return updateErr } } var disableErr error disabled, disableErr = svcCtx.SysPermModel.DisableNotInCodesWithTx(txCtx, session, product.Code, codes, now) return disableErr }) if err != nil { var se *SyncPermsError if errors.As(err, &se) { return nil, se } // 第 6 轮测试报告 §9.5#3:H-3 已经通过 LockByCodeTx 把同一产品的同步串行化,理论上 // sys_perm (productCode, code) UNIQUE 在事务内不可能再拿到 1062。若真的命中,说明: // (a) LockByCodeTx 没有生效(例如引擎/隔离级别被改); // (b) 有绕过本函数直接写 sys_perm 的代码路径被引入; // 任何一种都代表 H-3 的锁序契约失效,需要立即告警补回 409 重试契约。因此在这里落一条 // 带 audit=mysql_error_1062 + table=sys_perm 的 ERROR 级日志,日志采集侧即可据此建 // 指标与告警规则;对外仍然回通用 500 避免给客户端透传 DB 细节。 if util.IsDuplicateEntryErr(err) { logx.WithContext(ctx).Errorw("sync perms hit 1062 under LockByCodeTx — H-3 contract regressed", logx.Field("audit", "mysql_error_1062"), logx.Field("table", "sys_perm"), logx.Field("productCode", product.Code), logx.Field("err", err.Error()), ) } return nil, &SyncPermsError{Code: 500, Message: "同步权限事务失败"} } if added > 0 || updated > 0 || disabled > 0 { svcCtx.UserDetailsLoader.CleanByProduct(ctx, product.Code) } return &SyncPermsResult{ Added: added, Updated: updated, Disabled: disabled, }, nil }