Files
qitongxue-api/internal/logic/admin_batch.go
2026-09-29 10:57:01 +08:00

246 lines
7.5 KiB
Go
Raw 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 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
}