package logic import ( "context" "encoding/json" "sort" "strings" "time" "github.com/gogf/gf/v2/database/gdb" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/gtime" "golang.org/x/crypto/bcrypt" v1 "tool-api/api/admin/v1" "tool-api/internal/consts" "tool-api/internal/model/entity" ) // ===== 看板等级口径(唯一真实来源:EffectiveLevelKey / IsLevelExpired)===== // // 约定(见 level.go):users.level_expire_at 为 NULL 表示**永久有效**, // 管理员手动分配的等级一律为 NULL;只有已过期才回落默认等级。 // isActiveMember 是否为「有效会员」:生效等级 ≠ 默认等级即视为会员。 // 生效等级走 EffectiveLevelKey —— 永久(NULL)与未过期同为有效,已过期回落默认等级。 func isActiveMember(u *entity.Users) bool { return EffectiveLevelKey(u) != consts.DefaultLevelKey } // countActiveMembers 统计有效会员数(与 C 端 EffectiveLevel 口径完全一致)。 func countActiveMembers(users []*entity.Users) int64 { var n int64 for _, u := range users { if isActiveMember(u) { n++ } } return n } // levelDistribution 按「生效等级」聚合用户分布:已过期用户归入默认等级桶。 func levelDistribution(users []*entity.Users) map[string]int64 { dist := make(map[string]int64, 4) for _, u := range users { dist[EffectiveLevelKey(u)]++ } return dist } // resolveSort 解析排序参数:sortBy 必须命中白名单(key=前端字段,value=真实列名), // 未命中则回落到 fallback(形如 "id desc" / "sort asc")。返回 (column, direction)。 // 通过白名单映射,杜绝把用户输入直接拼进 ORDER BY 造成的注入。 func resolveSort(sortBy, sortOrder string, whitelist map[string]string, fallback string) (column, direction string) { const ( dirAsc = "asc" dirDesc = "desc" ) col, ok := whitelist[sortBy] if !ok || col == "" { // 解析 fallback:"列名 方向" parts := strings.Fields(fallback) if len(parts) == 2 { return parts[0], strings.ToLower(parts[1]) } if len(parts) == 1 { return parts[0], dirDesc } return "id", dirDesc } if strings.EqualFold(sortOrder, dirAsc) { return col, dirAsc } return col, dirDesc } // applySort 把 resolveSort 的结果作用到查询上。 func applySort(m *gdb.Model, sortBy, sortOrder string, whitelist map[string]string, fallback string) *gdb.Model { col, dir := resolveSort(sortBy, sortOrder, whitelist, fallback) if dir == "asc" { return m.OrderAsc(col) } return m.OrderDesc(col) } // ===== 登录 ===== func AdminLogin(ctx context.Context, account, password string) (*v1.LoginRes, error) { record, err := g.Model(consts.TableAdminUsers).Where("account", account).One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.New("账号或密码错误") } if err = bcrypt.CompareHashAndPassword([]byte(record["password"].String()), []byte(password)); err != nil { return nil, gerror.New("账号或密码错误") } nickName := record["nick_name"].String() roleName := record["role_name"].String() roleValue := record["role_value"].String() token, err := IssueToken(ctx, consts.AudAdmin, record["id"].Int64(), map[string]string{ "nickName": nickName, "roleName": roleName, "roleValue": roleValue, }) if err != nil { return nil, err } return &v1.LoginRes{Token: token, NickName: nickName, RoleName: roleName, RoleValue: roleValue}, nil } func AdminMyInfo(ctx context.Context) (*v1.MyInfoRes, error) { record, err := g.Model(consts.TableAdminUsers).Where("id", CtxAdminId(ctx)).One() if err != nil { return nil, err } if record.IsEmpty() { return nil, gerror.NewCode(gcodeUnauthorized(), "管理员不存在") } roleValue := record["role_value"].String() return &v1.MyInfoRes{ Id: record["id"].Int64(), Account: record["account"].String(), NickName: record["nick_name"].String(), RoleName: record["role_name"].String(), RoleValue: roleValue, Permissions: PermissionsOf(roleValue), }, nil } // ===== 用户管理 ===== func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, error) { if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } // 软删除:列表默认排除已删用户 m := g.Model(consts.TableUsers).WhereNull("deleted_at") if req.Keyword != "" { kw := "%" + req.Keyword + "%" m = m.Where("nickname LIKE ? OR openid LIKE ?", kw, kw) } // ===== T16 多条件筛选 ===== if req.LevelKey != "" { m = m.Where("level_key", req.LevelKey) } if req.Status != nil { m = m.Where("status", *req.Status) } if req.DateFrom != "" { m = m.WhereGTE("created_at", req.DateFrom+" 00:00:00") } if req.DateTo != "" { m = m.WhereLTE("created_at", req.DateTo+" 23:59:59") } // ===== T04.6 会员到期筛选 ===== if cond := memberExpireCondition(req.MemberExpire); cond != "" { m = m.Where(cond) } total, err := m.Count() if err != nil { return nil, err } // ===== T16 排序(白名单,仅真实列)===== var userSortWhitelist = map[string]string{ "id": "id", "created_at": "created_at", "level_key": "level_key", "status": "status", } records, err := applySort(m.Page(req.Page, req.PageSize), req.SortBy, req.SortOrder, userSortWhitelist, "id desc").All() if err != nil { return nil, err } levelNames, err := levelNameMap(ctx) if err != nil { return nil, err } list := make([]v1.AdminUserItem, 0, len(records)) for _, r := range records { item := v1.AdminUserItem{ Id: r["id"].Int64(), Nickname: r["nickname"].String(), AvatarUrl: r["avatar_url"].String(), Openid: r["openid"].String(), LevelKey: r["level_key"].String(), LevelName: levelNames[r["level_key"].String()], QuotaPeriodDays: r["quota_period_days"].Int(), Status: r["status"].Int(), CreatedAt: r["created_at"].String(), } if expire := r["level_expire_at"].GTime(); expire != nil && !expire.IsZero() { item.LevelExpireAt = expire.Format("Y-m-d") } list = append(list, item) } // 附加使用统计 if len(list) > 0 { ids := make([]int64, 0, len(list)) for _, u := range list { ids = append(ids, u.Id) } stats, err := g.Model(consts.TableUsageLogs). Fields("user_id, COUNT(*) AS c, MAX(used_at) AS last_used"). WhereIn("user_id", ids).Group("user_id").All() if err == nil { statMap := map[int64]gdb.Record{} for _, s := range stats { statMap[s["user_id"].Int64()] = s } for i := range list { if s, ok := statMap[list[i].Id]; ok { list[i].UsageCount = s["c"].Int64() list[i].LastUsedAt = s["last_used"].String() } } } } return &v1.UserListRes{List: list, Total: total}, nil } func levelNameMap(ctx context.Context) (map[string]string, error) { records, err := g.Model(consts.TableLevels).All() if err != nil { return nil, err } m := map[string]string{} for _, r := range records { m[r["level_key"].String()] = r["name"].String() } return m, nil } func AdminUserSetLevel(ctx context.Context, userId int64, levelKey, expireAt, remark string) (*v1.UserSetLevelRes, error) { // 等级必须存在且已启用,否则 4012(弹窗内红字,不关闭弹窗) level, err := g.Model(consts.TableLevels).Where("level_key", levelKey).Where("is_enabled", 1).One() if err != nil { return nil, err } if level.IsEmpty() { return nil, errLevelInvalid("等级不存在或已停用") } // 软删除:已删用户不可再操作等级 before, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() if err != nil { return nil, err } if before.IsEmpty() { return nil, gerror.New("用户不存在") } prevExpire := before["level_expire_at"].GTime() prevPermanent := prevExpire == nil || prevExpire.IsZero() newExpire := parseOptionalTime(expireAt) // 空 → nil → 写 NULL(永久) // 约定 S10:level_expire_at IS NULL = 永久;续费/手动开通**不得把永久覆写成有限期**。 // 判定抽为纯函数 levelExpireTransitionErr(见 admin_level_s10.go),便于单测覆盖。 if err = levelExpireTransitionErr(prevPermanent, newExpire); err != nil { return nil, err } // newExpire 为 nil 时写 NULL(GoFrame 将 nil 识别为 NULL) var expireValue interface{} if newExpire != nil { expireValue = newExpire } if _, err = g.Model(consts.TableUsers).Where("id", userId). Data(g.Map{ "level_key": levelKey, "level_expire_at": expireValue, "updated_at": gtime.Now(), }).Update(); err != nil { return nil, err } // T17 审计:改用户等级属敏感操作(remark 落审计备注) WriteAudit(ctx, AuditEntry{ Action: "user.set_level", TargetType: "user", TargetId: auditId(userId), Before: g.Map{"level_key": before["level_key"].String(), "level_expire_at": timeStr(prevExpire)}, After: g.Map{"level_key": levelKey, "level_expire_at": timeStr(newExpire)}, Result: consts.AuditResultSuccess, Remark: remark, }) return &v1.UserSetLevelRes{}, nil } // memberExpireCondition 把「会员到期」筛选项映射为 WHERE 条件(空/未知 → 不加条件)。 // // 口径与 EffectiveLevelKey 一致:level_expire_at IS NULL = 永久有效。 // - expiring7 / expiring30:7/30 天内到期 // - expired :已过期 // - forever :永久有效(NULL) // - none :未开通(仍是默认等级且无到期时间) func memberExpireCondition(kind string) string { now := time.Now() ts := func(t time.Time) string { return t.Format("2006-01-02 15:04:05") } switch kind { case "expiring7": return "level_expire_at IS NOT NULL AND level_expire_at > '" + ts(now) + "' AND level_expire_at <= '" + ts(now.AddDate(0, 0, 7)) + "'" case "expiring30": return "level_expire_at IS NOT NULL AND level_expire_at > '" + ts(now) + "' AND level_expire_at <= '" + ts(now.AddDate(0, 0, 30)) + "'" case "expired": return "level_expire_at IS NOT NULL AND level_expire_at <= '" + ts(now) + "'" case "forever": return "level_expire_at IS NULL" case "none": return "level_key = '" + consts.DefaultLevelKey + "' AND level_expire_at IS NULL" default: return "" } } func AdminUserStatus(ctx context.Context, userId int64, status int) (*v1.UserStatusRes, error) { // 软删除:已删用户不可再启停 before, err := g.Model(consts.TableUsers).Where("id", userId).WhereNull("deleted_at").One() if err != nil { return nil, err } if before.IsEmpty() { return nil, gerror.New("用户不存在") } if _, err := g.Model(consts.TableUsers).Where("id", userId). Data(g.Map{"status": status}).Update(); err != nil { return nil, err } WriteAudit(ctx, AuditEntry{ Action: "user.set_status", TargetType: "user", TargetId: auditId(userId), Before: g.Map{"status": before["status"].Int()}, After: g.Map{"status": status}, Result: consts.AuditResultSuccess, }) return &v1.UserStatusRes{}, nil } // ===== 等级管理 ===== func AdminLevelList(ctx context.Context) (*v1.LevelListRes, error) { records, err := g.Model(consts.TableLevels).Order("sort asc").All() if err != nil { return nil, err } list := make([]v1.LevelItem, 0, len(records)) for _, r := range records { modules := []string{} _ = json.Unmarshal([]byte(r["modules"].String()), &modules) list = append(list, v1.LevelItem{ Id: r["id"].Int64(), LevelKey: r["level_key"].String(), Name: r["name"].String(), Modules: modules, Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), Remark: r["remark"].String(), }) } return &v1.LevelListRes{List: list}, nil } func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes, error) { modulesJson, _ := json.Marshal(req.Modules) data := g.Map{ "name": req.Name, "modules": string(modulesJson), "sort": req.Sort, "is_enabled": req.IsEnabled, "remark": req.Remark, } if req.Id > 0 { // 编辑:level_key 是跨端契约字段,**不允许变更**。 // 前端把该输入框置为 disabled 只是体验层,服务端才是真闸; // 这里读回库中现值做校验(防止未来有人改坏 data map 或直接调接口)。 exist, err := g.Model(consts.TableLevels).Where("id", req.Id).One() if err != nil { return nil, err } if exist.IsEmpty() { return nil, gerror.New("等级不存在") } if err = validateLevelKeyUnchanged(exist["level_key"].String(), req.LevelKey); err != nil { return nil, err } if _, err := g.Model(consts.TableLevels).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } } else { count, err := g.Model(consts.TableLevels).Where("level_key", req.LevelKey).Count() if err != nil { return nil, err } if count > 0 { return nil, gerror.New("等级标识已存在") } data["level_key"] = req.LevelKey if _, err := g.Model(consts.TableLevels).Data(data).Insert(); err != nil { return nil, err } } return &v1.LevelSaveRes{}, nil } // validateLevelKeyUnchanged 校验「编辑等级时 level_key 未被变更」。 // level_key 是跨端契约字段(小程序按它判定权益),服务端必须保证编辑不可变更它。 // 抽成纯函数(不碰 DB)以便单测固化该闸门,见 admin_level_test.go。 func validateLevelKeyUnchanged(existing, incoming string) error { if existing != incoming { return gerror.New("等级标识不可修改(跨端契约)") } return nil } // ===== 模块管理 ===== func AdminModuleList(ctx context.Context) (*v1.ModuleListRes, error) { records, err := g.Model(consts.TableModules).Order("sort asc").All() if err != nil { return nil, err } // 软删除:模块工具数只统计未删工具 counts, err := g.Model(consts.TableTools).WhereNull("deleted_at").Fields("module_key, COUNT(*) AS c").Group("module_key").All() if err != nil { return nil, err } countMap := map[string]int{} for _, c := range counts { countMap[c["module_key"].String()] = c["c"].Int() } list := make([]v1.ModuleItem, 0, len(records)) for _, r := range records { key := r["module_key"].String() list = append(list, v1.ModuleItem{ Id: r["id"].Int64(), ModuleKey: key, Name: r["name"].String(), Icon: r["icon"].String(), Description: r["description"].String(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), ToolCount: countMap[key], }) } return &v1.ModuleListRes{List: list}, nil } func AdminModuleSave(ctx context.Context, req *v1.ModuleSaveReq) (*v1.ModuleSaveRes, error) { data := g.Map{ "name": req.Name, "icon": req.Icon, "description": req.Description, "sort": req.Sort, "is_enabled": req.IsEnabled, } if req.Id > 0 { if _, err := g.Model(consts.TableModules).Where("id", req.Id).Data(data).Update(); err != nil { return nil, err } } else { count, err := g.Model(consts.TableModules).Where("module_key", req.ModuleKey).Count() if err != nil { return nil, err } if count > 0 { return nil, gerror.New("模块标识已存在") } data["module_key"] = req.ModuleKey if _, err := g.Model(consts.TableModules).Data(data).Insert(); err != nil { return nil, err } } return &v1.ModuleSaveRes{}, nil } // ===== 工具管理 ===== func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes, error) { if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 50 } // 软删除:列表默认排除已删工具 m := g.Model(consts.TableTools).WhereNull("deleted_at") if req.ModuleKey != "" { m = m.Where("module_key", req.ModuleKey) } if req.Keyword != "" { kw := "%" + req.Keyword + "%" m = m.Where("name LIKE ? OR tool_key LIKE ?", kw, kw) } // ===== T16 多条件筛选(显式 is_enabled 优先于 includeDisabled 开关)===== if req.IsEnabled != nil { m = m.Where("is_enabled", *req.IsEnabled) } else if !req.IncludeDisabled { m = m.Where("is_enabled", 1) } if req.IsHot != nil { m = m.Where("is_hot", *req.IsHot) } total, err := m.Count() if err != nil { return nil, err } // ===== T16 排序(白名单)===== var toolSortWhitelist = map[string]string{ "id": "id", "sort": "sort", "usage_count": "usage_count", "is_enabled": "is_enabled", "is_hot": "is_hot", "name": "name", "module_key": "module_key", } records, err := applySort(m.Page(req.Page, req.PageSize), req.SortBy, req.SortOrder, toolSortWhitelist, "sort asc").All() if err != nil { return nil, err } moduleNames, err := moduleNameMap(ctx) if err != nil { return nil, err } list := make([]v1.AdminToolItem, 0, len(records)) for _, r := range records { mk := r["module_key"].String() list = append(list, v1.AdminToolItem{ Id: r["id"].Int64(), ToolKey: r["tool_key"].String(), ModuleKey: mk, ModuleName: moduleNames[mk], Name: r["name"].String(), Icon: r["icon"].String(), Description: r["description"].String(), FreeQuota: r["free_quota"].Int(), MemberQuota: r["member_quota"].Int(), PayCost: r["pay_cost"].Int(), Sort: r["sort"].Int(), IsEnabled: r["is_enabled"].Int(), IsHot: r["is_hot"].Int(), UsageCount: r["usage_count"].Int64(), }) } return &v1.ToolsListRes{List: list, Total: total}, nil } func moduleNameMap(ctx context.Context) (map[string]string, error) { records, err := g.Model(consts.TableModules).All() if err != nil { return nil, err } m := map[string]string{} for _, r := range records { m[r["module_key"].String()] = r["name"].String() } return m, nil } func AdminToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (*v1.ToolsSaveRes, error) { payCost := req.PayCost if payCost <= 0 { payCost = 1 } data := g.Map{ "module_key": req.ModuleKey, "name": req.Name, "icon": req.Icon, "description": req.Description, "free_quota": req.FreeQuota, "member_quota": req.MemberQuota, "pay_cost": payCost, "sort": req.Sort, "is_enabled": req.IsEnabled, "is_hot": req.IsHot, } if req.Id > 0 { // 编辑:tool_key 只读,不修改 if _, err := g.Model(consts.TableTools).Where("id", req.Id).WhereNull("deleted_at").Data(data).Update(); err != nil { return nil, err } } else { count, err := g.Model(consts.TableTools).Where("tool_key", req.ToolKey).Count() if err != nil { return nil, err } if count > 0 { return nil, gerror.New("工具标识已存在") } data["tool_key"] = req.ToolKey if _, err := g.Model(consts.TableTools).Data(data).Insert(); err != nil { return nil, err } } return &v1.ToolsSaveRes{}, nil } func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (*v1.ToolsToggleRes, error) { if field != "is_enabled" && field != "is_hot" { return nil, gerror.New("不支持的字段") } if _, err := g.Model(consts.TableTools).Where("id", id).WhereNull("deleted_at").Data(g.Map{field: value}).Update(); err != nil { return nil, err } return &v1.ToolsToggleRes{}, nil } // ===== 数据看板 ===== func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) { res := &v1.DashboardStatsRes{ WeekTrend: []v1.TrendItem{}, ToolsTop: []v1.TopItem{}, RevenueTrend: []v1.AmountItem{}, LevelDist: []v1.LevelDistItem{}, OrderDist: []v1.DistItem{}, } if c, err := g.Model(consts.TableUsers).WhereNull("deleted_at").Count(); err == nil { res.UserCount = int64(c) } if c, err := g.Model(consts.TableUsageLogs).Count(); err == nil { res.TotalUsage = int64(c) } // 今日活跃:今天有使用记录或登录过的用户数 record, err := g.DB().GetOne(ctx, "SELECT COUNT(*) AS c FROM users u WHERE u.deleted_at IS NULL AND (DATE(u.last_login_at) = CURDATE() "+ "OR EXISTS (SELECT 1 FROM tool_usage_logs l WHERE l.user_id = u.id AND DATE(l.used_at) = CURDATE()))") if err == nil && !record.IsEmpty() { res.TodayActive = record["c"].Int64() } // 7 日使用趋势(缺失日期补 0) trend, err := g.DB().GetAll(ctx, "SELECT DATE(used_at) AS d, COUNT(*) AS c FROM tool_usage_logs "+ "WHERE used_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY d") if err == nil { countByDate := map[string]int64{} for _, r := range trend { countByDate[r["d"].String()] = r["c"].Int64() } for i := 6; i >= 0; i-- { day := time.Now().AddDate(0, 0, -i).Format("2006-01-02") res.WeekTrend = append(res.WeekTrend, v1.TrendItem{Date: day, Count: countByDate[day]}) } } // 工具使用 TOP10 top, err := g.DB().GetAll(ctx, "SELECT tool_key, COUNT(*) AS c FROM tool_usage_logs GROUP BY tool_key ORDER BY c DESC LIMIT 10") if err == nil { names, _ := toolNameMap(ctx) for _, r := range top { key := r["tool_key"].String() res.ToolsTop = append(res.ToolsTop, v1.TopItem{ToolKey: key, Name: names[key], Count: r["c"].Int64()}) } } // ===== T16 指标扩充:会员 / 收入 / 额度 / 分布 ===== // 有效会员 + 等级分布:一次取出 (level_key, level_expire_at),在 Go 层用 // EffectiveLevelKey 归一 —— 与 C 端 EffectiveLevel 口径**完全一致**(唯一真实来源)。 // 关键修复:level_expire_at 为 NULL 表示永久有效,必须计入有效会员 // (旧 SQL 用 `level_expire_at IS NOT NULL` 把所有永久会员漏掉了)。 var memberUsers []*entity.Users if rows, err := g.DB().GetAll(ctx, "SELECT level_key, level_expire_at FROM users WHERE deleted_at IS NULL"); err == nil { memberUsers = make([]*entity.Users, 0, len(rows)) for _, r := range rows { memberUsers = append(memberUsers, &entity.Users{ LevelKey: r["level_key"].String(), LevelExpireAt: r["level_expire_at"].GTime(), }) } } res.MemberCount = countActiveMembers(memberUsers) // 已发货订单数 + 累计收入(只统计已发货,待支付/退款不计入) if r, err := g.DB().GetOne(ctx, "SELECT COUNT(*) AS c, COALESCE(SUM(price_cents),0) AS amt FROM member_orders WHERE status = ?", consts.OrderStatusDelivered); err == nil && !r.IsEmpty() { res.OrderCount = r["c"].Int64() res.RevenueCents = r["amt"].Int64() } // 近 7 日收入趋势(按发货时间聚合,缺失日期补 0) revTrend, err := g.DB().GetAll(ctx, "SELECT DATE(delivered_at) AS d, COALESCE(SUM(price_cents),0) AS amt FROM member_orders "+ "WHERE status = ? AND delivered_at >= DATE_SUB(CURDATE(), INTERVAL 6 DAY) GROUP BY d", consts.OrderStatusDelivered) if err == nil { amtByDate := map[string]int64{} for _, r := range revTrend { amtByDate[r["d"].String()] = r["amt"].Int64() } for i := 6; i >= 0; i-- { day := time.Now().AddDate(0, 0, -i).Format("2006-01-02") res.RevenueTrend = append(res.RevenueTrend, v1.AmountItem{Date: day, Cents: amtByDate[day]}) } } // 额度使用:本周期免费 / 会员已用合计 if r, err := g.DB().GetOne(ctx, "SELECT COALESCE(SUM(free_used),0) AS f, COALESCE(SUM(member_used),0) AS m FROM user_quota_usage"); err == nil && !r.IsEmpty() { res.QuotaFreeUsed = r["f"].Int64() res.QuotaMemberUsed = r["m"].Int64() } // 付费额度累计消耗 = Σ(累计购买 - 当前剩余) if v, err := g.DB().GetValue(ctx, "SELECT COALESCE(SUM(total_bought - times_left),0) FROM user_tool_quota"); err == nil { res.QuotaPaidUsed = v.Int64() } // 等级分布:按「生效等级」聚合(已过期用户归入默认等级桶),复用上面的 memberUsers。 // 展示名:优先取等级名;默认等级无名时显示「普通用户」;其余回落 key;空 key 归「未知」。 { names, _ := levelNameMap(ctx) dist := levelDistribution(memberUsers) keys := make([]string, 0, len(dist)) for k := range dist { keys = append(keys, k) } // 稳定性:数量降序,数量相同按 key 升序(图表展示直观且结果确定) sort.Slice(keys, func(i, j int) bool { if dist[keys[i]] != dist[keys[j]] { return dist[keys[i]] > dist[keys[j]] } return keys[i] < keys[j] }) for _, key := range keys { name := names[key] if name == "" { switch { case key == consts.DefaultLevelKey: name = "普通用户" case key != "": name = key default: name = "未知" } } res.LevelDist = append(res.LevelDist, v1.LevelDistItem{LevelKey: key, Name: name, Count: dist[key]}) } } // 订单构成(会员套餐 / 次数包,仅已发货) orderCounts, err := g.DB().GetAll(ctx, "SELECT order_type, COUNT(*) AS c FROM member_orders WHERE status = ? GROUP BY order_type", consts.OrderStatusDelivered) if err == nil { byType := map[int]int64{} for _, r := range orderCounts { byType[r["order_type"].Int()] = r["c"].Int64() } res.OrderDist = append(res.OrderDist, v1.DistItem{Key: "member", Label: "会员套餐", Value: byType[consts.OrderTypeMember]}, v1.DistItem{Key: "quota", Label: "次数包", Value: byType[consts.OrderTypeQuota]}, ) } return res, nil } func toolNameMap(ctx context.Context) (map[string]string, error) { records, err := g.Model(consts.TableTools).All() if err != nil { return nil, err } m := map[string]string{} for _, r := range records { m[r["tool_key"].String()] = r["name"].String() } return m, nil } // ===== 反馈管理 ===== func AdminFeedbackList(ctx context.Context, req *v1.FeedbackListReq) (*v1.FeedbackListRes, error) { if req.Page <= 0 { req.Page = 1 } if req.PageSize <= 0 { req.PageSize = 20 } m := g.Model(consts.TableFeedbacks) if req.Status != nil { m = m.Where("status", *req.Status) } total, err := m.Count() if err != nil { return nil, err } records, err := m.Page(req.Page, req.PageSize).Order("id desc").All() if err != nil { return nil, err } list := make([]v1.FeedbackItem, 0, len(records)) for _, r := range records { list = append(list, v1.FeedbackItem{ Id: r["id"].Int64(), UserId: r["user_id"].Int64(), Content: r["content"].String(), Contact: r["contact"].String(), Status: r["status"].Int(), CreatedAt: r["created_at"].String(), }) } return &v1.FeedbackListRes{List: list, Total: total}, nil } func AdminFeedbackHandle(ctx context.Context, id int64) (*v1.FeedbackHandleRes, error) { if _, err := g.Model(consts.TableFeedbacks).Where("id", id).Data(g.Map{"status": 1}).Update(); err != nil { return nil, err } return &v1.FeedbackHandleRes{}, nil }