小程序一些功能更新

This commit is contained in:
李琦
2026-09-29 10:57:01 +08:00
parent 0cb70a7618
commit 3bb7fa8db3
96 changed files with 13890 additions and 213 deletions

View File

@@ -3,17 +3,87 @@ 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) {
@@ -49,12 +119,14 @@ func AdminMyInfo(ctx context.Context) (*v1.MyInfoRes, error) {
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: record["role_value"].String(),
Id: record["id"].Int64(),
Account: record["account"].String(),
NickName: record["nick_name"].String(),
RoleName: record["role_name"].String(),
RoleValue: roleValue,
Permissions: PermissionsOf(roleValue),
}, nil
}
@@ -67,16 +139,38 @@ func AdminUserList(ctx context.Context, req *v1.UserListReq) (*v1.UserListRes, e
if req.PageSize <= 0 {
req.PageSize = 20
}
m := g.Model(consts.TableUsers)
// 软删除:列表默认排除已删用户
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
}
records, err := m.Page(req.Page, req.PageSize).Order("id desc").All()
// ===== 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
}
@@ -139,26 +233,108 @@ func levelNameMap(ctx context.Context) (map[string]string, error) {
return m, nil
}
func AdminUserSetLevel(ctx context.Context, userId int64, levelKey string) (*v1.UserSetLevelRes, error) {
level, err := g.Model(consts.TableLevels).Where("level_key", levelKey).One()
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, gerror.New("等级不存在")
return nil, errLevelInvalid("等级不存在或已停用")
}
if _, err = g.Model(consts.TableUsers).Where("id", userId).
Data(g.Map{"level_key": levelKey}).Update(); err != nil {
// 软删除:已删用户不可再操作等级
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
}
@@ -196,7 +372,19 @@ func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes
"remark": req.Remark,
}
if req.Id > 0 {
// 编辑:level_key 只读,不修改
// 编辑: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
}
@@ -216,6 +404,16 @@ func AdminLevelSave(ctx context.Context, req *v1.LevelSaveReq) (*v1.LevelSaveRes
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) {
@@ -223,7 +421,8 @@ func AdminModuleList(ctx context.Context) (*v1.ModuleListRes, error) {
if err != nil {
return nil, err
}
counts, err := g.Model(consts.TableTools).Fields("module_key, COUNT(*) AS c").Group("module_key").All()
// 软删除:模块工具数只统计未删工具
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
}
@@ -285,7 +484,8 @@ func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes
if req.PageSize <= 0 {
req.PageSize = 50
}
m := g.Model(consts.TableTools)
// 软删除:列表默认排除已删工具
m := g.Model(consts.TableTools).WhereNull("deleted_at")
if req.ModuleKey != "" {
m = m.Where("module_key", req.ModuleKey)
}
@@ -293,14 +493,25 @@ func AdminToolsList(ctx context.Context, req *v1.ToolsListReq) (*v1.ToolsListRes
kw := "%" + req.Keyword + "%"
m = m.Where("name LIKE ? OR tool_key LIKE ?", kw, kw)
}
if !req.IncludeDisabled {
// ===== 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
}
records, err := m.Page(req.Page, req.PageSize).Order("sort asc").All()
// ===== 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
}
@@ -362,7 +573,7 @@ func AdminToolsSave(ctx context.Context, req *v1.ToolsSaveReq) (*v1.ToolsSaveRes
}
if req.Id > 0 {
// 编辑:tool_key 只读,不修改
if _, err := g.Model(consts.TableTools).Where("id", req.Id).Data(data).Update(); err != nil {
if _, err := g.Model(consts.TableTools).Where("id", req.Id).WhereNull("deleted_at").Data(data).Update(); err != nil {
return nil, err
}
} else {
@@ -385,7 +596,7 @@ func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (*
if field != "is_enabled" && field != "is_hot" {
return nil, gerror.New("不支持的字段")
}
if _, err := g.Model(consts.TableTools).Where("id", id).Data(g.Map{field: value}).Update(); err != nil {
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
@@ -394,8 +605,14 @@ func AdminToolsToggle(ctx context.Context, id int64, field string, value int) (*
// ===== 数据看板 =====
func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) {
res := &v1.DashboardStatsRes{WeekTrend: []v1.TrendItem{}, ToolsTop: []v1.TopItem{}}
if c, err := g.Model(consts.TableUsers).Count(); err == nil {
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 {
@@ -403,8 +620,8 @@ func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) {
}
// 今日活跃:今天有使用记录或登录过的用户数
record, err := g.DB().GetOne(ctx,
"SELECT COUNT(*) AS c FROM users u WHERE 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())")
"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()
}
@@ -432,6 +649,108 @@ func AdminDashboardStats(ctx context.Context) (*v1.DashboardStatsRes, error) {
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
}