836 lines
27 KiB
Go
836 lines
27 KiB
Go
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
|
||
}
|