package logic import ( "context" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" ) // ============================================================================ // 批量操作(T15 批量改值 + T18 软删除) // // 设计要点: // - 幂等:同一批重复提交结果一致;把值改为当前值仍算成功;对**已软删**的 id 再次 // 执行 delete 也计成功(不塞进 failures); // - 部分成功:不存在的 id 计入 failed 并附原因,不影响其余 id 的更新; // - 高效:存在性一次 IN 查询、更新一次 IN 批量,避免 N 次单条请求。 // // action 白名单:user = status / level / delete;tool = is_enabled / is_hot / delete; // employee = status / delete。 // ============================================================================ // dedupBatchIds 去重并剔除非法 id(<=0),保持首次出现顺序。 func dedupBatchIds(in []int64) []int64 { seen := make(map[int64]bool, len(in)) out := make([]int64, 0, len(in)) for _, id := range in { if id <= 0 || seen[id] { continue } seen[id] = true out = append(out, id) } return out } // partitionBatchIds 按「是否存在」把 id 分为可操作(ok)与缺失(missing),保持入参顺序。 func partitionBatchIds(requested []int64, existing map[int64]bool) (ok []int64, missing []int64) { ok = make([]int64, 0, len(requested)) missing = make([]int64, 0) for _, id := range requested { if existing[id] { ok = append(ok, id) continue } missing = append(missing, id) } return ok, missing } // batchFailures 把缺失 id 转为失败明细。 func batchFailures(missing []int64, reason string) []v1.BatchFailure { out := make([]v1.BatchFailure, 0, len(missing)) for _, id := range missing { out = append(out, v1.BatchFailure{Id: id, Reason: reason}) } return out } // existingIdSet 一次 IN 查询出「实际存在的 id 集合」。 // // 🔴 必须 `.Unscoped()`:GoFrame v2.10 会给所有 Model 查询(select/count/update/delete) // **自动**追加 `deleted_at IS NULL`(见 softdelete.go 头部不变量)。若不加 Unscoped, // 已软删的行会被自动过滤掉 → 落进 missing → 被判为「不存在」→ 返回 failed, // 直接违反「重复删除已删 id 计成功」的幂等契约(且代码注释与实现相反,极隐蔽)。 // Unscoped 让软删行照常出现在结果里、计入 ok —— delete 幂等正是依赖此语义。 func existingIdSet(ctx context.Context, table string, ids []int64) (map[int64]bool, error) { set := make(map[int64]bool, len(ids)) if len(ids) == 0 { return set, nil } records, err := g.Model(table).Unscoped().Fields("id").WhereIn("id", ids).All() if err != nil { return nil, err } for _, r := range records { set[r["id"].Int64()] = true } return set, nil } // normalizeToggle 归一化为 0/1。 func normalizeToggle(v int) int { if v == 1 { return 1 } return 0 } // AdminUserBatch 批量操作用户:status(启停)/ level(改等级)/ delete(软删除,可恢复)。 func AdminUserBatch(ctx context.Context, req *v1.UserBatchReq) (*v1.UserBatchRes, error) { ids := dedupBatchIds(req.Ids) if len(ids) == 0 { return &v1.UserBatchRes{Failures: []v1.BatchFailure{}}, nil } existing, err := existingIdSet(ctx, consts.TableUsers, ids) if err != nil { return nil, err } ok, missing := partitionBatchIds(ids, existing) switch req.Action { case "delete": // 软删除:墓碑化 openid/username,保证可重新注册;已删 id 幂等计成功。 if err = softDeleteUsers(ctx, ok); err != nil { return nil, err } case "status", "level": data := g.Map{} if req.Action == "status" { data["status"] = normalizeToggle(req.Status) } else { n, err := g.Model(consts.TableLevels).Where("level_key", req.LevelKey).Count() if err != nil { return nil, err } if n == 0 { return nil, gerror.New("等级不存在") // 参数错误 → 整体失败,不做「部分成功」 } data["level_key"] = req.LevelKey } if len(ok) > 0 { if _, err = g.Model(consts.TableUsers).WhereIn("id", ok).Data(data).Update(); err != nil { return nil, err } } default: return nil, gerror.New("不支持的批量动作") } res := &v1.UserBatchRes{ Success: len(ok), Failed: len(missing), Failures: batchFailures(missing, "用户不存在"), } WriteAudit(ctx, AuditEntry{ Action: "user.batch_" + req.Action, TargetType: "user", TargetId: "batch", After: g.Map{ "action": req.Action, "ids": ok, "status": req.Status, "level_key": req.LevelKey, "success": res.Success, "failed": res.Failed, }, Result: consts.AuditResultSuccess, }) return res, nil } // AdminToolsBatch 批量操作工具:is_enabled / is_hot(改值)/ delete(软删除,可恢复)。 func AdminToolsBatch(ctx context.Context, req *v1.ToolsBatchReq) (*v1.ToolsBatchRes, error) { ids := dedupBatchIds(req.Ids) if len(ids) == 0 { return &v1.ToolsBatchRes{Failures: []v1.BatchFailure{}}, nil } existing, err := existingIdSet(ctx, consts.TableTools, ids) if err != nil { return nil, err } ok, missing := partitionBatchIds(ids, existing) switch req.Action { case "delete": // 软删除:仅置 deleted_at(不墓碑化 tool_key,见 softdelete.go 说明)。 if err = softDeleteSimple(ctx, consts.TableTools, ok); err != nil { return nil, err } case "is_enabled", "is_hot": if len(ok) > 0 { if _, err = g.Model(consts.TableTools).WhereIn("id", ok). Data(g.Map{req.Action: normalizeToggle(req.Value)}).Update(); err != nil { return nil, err } } default: return nil, gerror.New("不支持的批量动作") } res := &v1.ToolsBatchRes{ Success: len(ok), Failed: len(missing), Failures: batchFailures(missing, "工具不存在"), } WriteAudit(ctx, AuditEntry{ Action: "tool.batch_" + req.Action, TargetType: "tool", TargetId: "batch", After: g.Map{ "action": req.Action, "value": normalizeToggle(req.Value), "ids": ok, "success": res.Success, "failed": res.Failed, }, Result: consts.AuditResultSuccess, }) return res, nil } // AdminEmployeeBatch 批量操作员工:status(启停)/ delete(软删除,可恢复)。 func AdminEmployeeBatch(ctx context.Context, req *v1.EmployeeBatchReq) (*v1.EmployeeBatchRes, error) { ids := dedupBatchIds(req.Ids) if len(ids) == 0 { return &v1.EmployeeBatchRes{Failures: []v1.BatchFailure{}}, nil } existing, err := existingIdSet(ctx, consts.TableEmployees, ids) if err != nil { return nil, err } ok, missing := partitionBatchIds(ids, existing) switch req.Action { case "delete": // 软删除:墓碑化 phone,保证同企业同手机号可重新录入。 if err = softDeleteEmployees(ctx, ok); err != nil { return nil, err } case "status": if len(ok) > 0 { if _, err = g.Model(consts.TableEmployees).WhereIn("id", ok). Data(g.Map{"status": normalizeToggle(req.Status)}).Update(); err != nil { return nil, err } } default: return nil, gerror.New("不支持的批量动作") } res := &v1.EmployeeBatchRes{ Success: len(ok), Failed: len(missing), Failures: batchFailures(missing, "员工不存在"), } // T17/T18:员工批量也纳入审计(与 user/tool 批量保持一致) WriteAudit(ctx, AuditEntry{ Action: "employee.batch_" + req.Action, TargetType: "employee", TargetId: "batch", After: g.Map{ "action": req.Action, "ids": ok, "status": req.Status, "success": res.Success, "failed": res.Failed, }, Result: consts.AuditResultSuccess, }) return res, nil }