小程序一些功能更新
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user