Files
qitongxue-api/internal/logic/admin.go
2026-09-29 15:48:13 +08:00

836 lines
27 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"
"encoding/json"
"sort"
"strconv"
"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(),
Reply: r["reply_content"].String(),
ReplyAt: r["reply_at"].String(),
CreatedAt: r["created_at"].String(),
})
}
return &v1.FeedbackListRes{List: list, Total: total}, nil
}
// AdminFeedbackHandle 反馈处理:标记已处理 + 可选官方回复。
// 带回复时置 reply_read=0(用户端红点提醒),再次回复会重新置为未读。
func AdminFeedbackHandle(ctx context.Context, id int64, reply string) (*v1.FeedbackHandleRes, error) {
reply = strings.TrimSpace(reply)
if len(reply) > 1000 {
reply = reply[:1000]
}
data := g.Map{"status": 1}
if reply != "" {
data["reply_content"] = reply
data["reply_by"] = CtxAdminId(ctx)
data["reply_at"] = gtime.Now()
data["reply_read"] = 0
}
if _, err := g.Model(consts.TableFeedbacks).Where("id", id).Data(data).Update(); err != nil {
return nil, err
}
if reply != "" {
WriteAudit(ctx, AuditEntry{
Action: "feedback.reply",
TargetType: "feedback",
TargetId: strconv.FormatInt(id, 10),
After: g.Map{"status": 1, "reply_len": len(reply)},
Result: consts.AuditResultSuccess,
Remark: "回复用户反馈",
})
}
return &v1.FeedbackHandleRes{}, nil
}