Files
zt-closet-manage-backend/internal/mysqldao/stats.go
T
zhengchiliu 0863234bc5 feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
2026-09-16 02:58:56 +08:00

516 lines
15 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 mysqldao
import (
"context"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
"gorm.io/gorm"
)
// StatsDao 仪表盘聚合统计数据访问
type StatsDao struct {
db *gorm.DB
}
// NewStatsDao 创建统计 DAO
func NewStatsDao() *StatsDao {
return &StatsDao{db: global.MysqlEngine}
}
// UserStats 用户统计。
type UserStats struct {
Total int64 // 用户总数
TodayNew int64 // 今日新增
Normal int64 // 正常
TempBan int64 // 临时禁用
PermBan int64 // 永久禁用
Deactivated int64 // 已注销
}
// OrderStats 订单统计。
type OrderStats struct {
Total int64 // 订单总数
Pending int64 // 待支付
Paid int64 // 已支付
Cancelled int64 // 已取消
Expired int64 // 已过期
Refunded int64 // 已退款
}
// RevenueStats 营收统计(单位分)。
type RevenueStats struct {
TotalRevenue int64 // 累计营收(已支付+已退款订单实付金额)
TodayRevenue int64 // 今日营收(今日支付订单实付金额)
RefundTotal int64 // 累计退款金额
}
// TodoStats 待办统计。
// 穿搭相关指标(公开展示中数量、已封禁数量)由 ContentStatsGet 一并聚合,此处不重复查询。
type TodoStats struct {
RefundPending int64 // 待处理退款数
GoodsOnShelf int64 // 上架商品数
}
// UserStatsGet 聚合用户总数与今日新增
func (d *StatsDao) UserStatsGet(ctx context.Context, todayStart int64) (*UserStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGet")
defer span.End()
s := &UserStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Count(&s.Total).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("created_at >= ?", todayStart).Count(&s.TodayNew).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// UserStatsGetByStatus 按状态统计用户数(单次 group by)
func (d *StatsDao) UserStatsGetByStatus(ctx context.Context) (map[int8]int64, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGetByStatus")
defer span.End()
rows := []struct {
Status int8
Cnt int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Select("status, COUNT(*) AS cnt").
Group("status").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
m := make(map[int8]int64, len(rows))
for _, r := range rows {
m[r.Status] = r.Cnt
}
return m, nil
}
// OrderStatsGet 聚合订单状态统计(单次 group by)
func (d *StatsDao) OrderStatsGet(ctx context.Context) (*OrderStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderStatsGet")
defer span.End()
rows := []struct {
OrderStatus int8
Cnt int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Select("order_status, COUNT(*) AS cnt").
Group("order_status").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
s := &OrderStats{}
for _, r := range rows {
s.Total += r.Cnt
switch r.OrderStatus {
case 1:
s.Pending = r.Cnt
case 2:
s.Paid = r.Cnt
case 3:
s.Cancelled = r.Cnt
case 4:
s.Expired = r.Cnt
case 5:
s.Refunded = r.Cnt
}
}
return s, nil
}
// RevenueStatsGet 聚合营收统计(已支付与已退款订单均计营收;今日按支付时间统计)
func (d *StatsDao) RevenueStatsGet(ctx context.Context, todayStart int64) (*RevenueStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.RevenueStatsGet")
defer span.End()
s := &RevenueStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ?", []int8{2, 5}).
Select("COALESCE(SUM(pay_amount), 0)").
Scan(&s.TotalRevenue).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, todayStart).
Select("COALESCE(SUM(pay_amount), 0)").
Scan(&s.TodayRevenue).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status = ?", int8(5)).
Select("COALESCE(SUM(refund_amount), 0)").
Scan(&s.RefundTotal).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// TodoStatsGet 聚合待办统计
func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.TodoStatsGet")
defer span.End()
s := &TodoStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
Where("status = ?", int8(0)).
Count(&s.RefundPending).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(1)).
Count(&s.GoodsOnShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// outfitDistRow 穿搭 (is_public, status) 组合分布行。
type outfitDistRow struct {
IsPublic int8
Status int8
Cnt int64
}
// countByInt8 通用分组计数行(小整型枚举 + 计数)。
type countByInt8 struct {
K int8
Cnt int64
}
// dateCount 按日分组计数行。
type dateCount struct {
D string
Cnt int64
}
// dateRevenue 按日分组营收行。
type dateRevenue struct {
D string
Cnt int64
Rev int64
}
// ContentStats 内容审核统计。
type ContentStats struct {
OutfitTotal int64 // 穿搭总数
OutfitPublic int64 // 公开展示中(is_public=2 且未封禁)
OutfitPrivate int64 // 私密(is_public=1 且未封禁)
OutfitBanned int64 // 已封禁(status=2)
ClothesTotal int64 // 衣物总数
ClothesProcessing int64 // 处理中(抠图/分类/排队)
ClothesSuccess int64 // 处理成功
ClothesFailed int64 // 处理失败
ShareTotal int64 // 衣柜分享总数
ShareNormal int64 // 正常
ShareCancelled int64 // 用户取消
ShareExpired int64 // 已过期
ShareBanned int64 // 管理员封禁
}
// VIPStats 会员统计。
type VIPStats struct {
MemberTotal int64
Monthly int64
Yearly int64
Trial int64
ExpiringSoon int64
}
// GoodsTypeCount 商品类型数量。
type GoodsTypeCount struct {
Type int8
Count int64
}
// GoodsStats 商品统计。
type GoodsStats struct {
Total int64
OnShelf int64
OffShelf int64
ByType []GoodsTypeCount
}
// TrendStats 趋势原始聚合(按 YYYY-MM-DD 分组)。
type TrendStats struct {
NewUsers map[string]int64 // 新增用户
Orders map[string]int64 // 已支付订单量
Revenue map[string]int64 // 营收(分)
}
// ContentStatsGet 聚合内容审核概况:穿搭/衣物/衣柜分享。
func (d *StatsDao) ContentStatsGet(ctx context.Context) (*ContentStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.ContentStatsGet")
defer span.End()
s := &ContentStats{}
// 穿搭:一次 group by 取回 (is_public, status) 组合分布。
// 业务侧「公开穿搭」会在同一次更新中写入 is_public=2 与 audit_status=2(自动过审),
// 因此审核状态不再作为公开的前置条件,内容治理以封禁为准;这里按 is_public 统计公开/私密,
// 并把已封禁(status=2)单独成列、从公开/私密中剔除,使三者互不重叠、饼图不会重复计数。
outfitRows := []outfitDistRow{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
Select("is_public, status, COUNT(*) AS cnt").
Group("is_public, status").
Scan(&outfitRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range outfitRows {
s.OutfitTotal += r.Cnt
switch {
case r.Status == int8(2): // 已封禁
s.OutfitBanned += r.Cnt
case r.IsPublic == int8(2): // 公开
s.OutfitPublic += r.Cnt
case r.IsPublic == int8(1): // 私密
s.OutfitPrivate += r.Cnt
}
}
// 衣物:image_process_status 分布(1/2/3 处理中,4 成功,5 失败)
clothesRows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
Select("image_process_status AS k, COUNT(*) AS cnt").
Group("image_process_status").
Scan(&clothesRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range clothesRows {
s.ClothesTotal += r.Cnt
switch r.K {
case 1, 2, 3:
s.ClothesProcessing += r.Cnt
case 4:
s.ClothesSuccess = r.Cnt
case 5:
s.ClothesFailed = r.Cnt
}
}
// 衣柜分享:status 分布
shareRows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).
Select("status AS k, COUNT(*) AS cnt").
Group("status").
Scan(&shareRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range shareRows {
s.ShareTotal += r.Cnt
switch r.K {
case 1:
s.ShareNormal = r.Cnt
case 2:
s.ShareCancelled = r.Cnt
case 3:
s.ShareExpired = r.Cnt
case 4:
s.ShareBanned = r.Cnt
}
}
return s, nil
}
// VIPStatsGet 聚合会员概况(有效会员 + 即将到期)。
func (d *StatsDao) VIPStatsGet(ctx context.Context, now, soonThreshold int64) (*VIPStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPStatsGet")
defer span.End()
s := &VIPStats{}
activeVIP := []int8{2, 3, 4}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type IN ? AND vip_expire > ?", activeVIP, now).
Count(&s.MemberTotal).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(2), now).
Count(&s.Monthly).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(3), now).
Count(&s.Yearly).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type = ? AND vip_expire > ?", int8(4), now).
Count(&s.Trial).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("vip_type IN ? AND vip_expire >= ? AND vip_expire <= ?", activeVIP, now, soonThreshold).
Count(&s.ExpiringSoon).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}
// GoodsStatsGet 聚合商品统计(上架/下架 + 类型分布)。
func (d *StatsDao) GoodsStatsGet(ctx context.Context) (*GoodsStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsStatsGet")
defer span.End()
s := &GoodsStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Count(&s.Total).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(1)).
Count(&s.OnShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Where("status = ?", int8(2)).
Count(&s.OffShelf).Error; err != nil {
span.RecordError(err)
return nil, err
}
rows := []countByInt8{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
Select("goods_type AS k, COUNT(*) AS cnt").
Group("goods_type").
Scan(&rows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range rows {
s.ByType = append(s.ByType, GoodsTypeCount{Type: r.K, Count: r.Cnt})
}
return s, nil
}
// TrendStatsGet 聚合近 N 天趋势(新增用户/已支付订单量/营收,按天分组)。
func (d *StatsDao) TrendStatsGet(ctx context.Context, startTs int64) (*TrendStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.TrendStatsGet")
defer span.End()
s := &TrendStats{
NewUsers: map[string]int64{},
Orders: map[string]int64{},
Revenue: map[string]int64{},
}
// 新增用户:按创建时间
userRows := []dateCount{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
Where("created_at >= ?", startTs).
Select("DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') AS d, COUNT(*) AS cnt").
Group("d").
Scan(&userRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range userRows {
s.NewUsers[r.D] = r.Cnt
}
// 已支付/已退款订单量 + 营收:按支付时间
orderRows := []dateRevenue{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, startTs).
Select("DATE_FORMAT(FROM_UNIXTIME(pay_time), '%Y-%m-%d') AS d, COUNT(*) AS cnt, COALESCE(SUM(pay_amount), 0) AS rev").
Group("d").
Scan(&orderRows).Error; err != nil {
span.RecordError(err)
return nil, err
}
for _, r := range orderRows {
s.Orders[r.D] = r.Cnt
s.Revenue[r.D] = r.Rev
}
return s, nil
}
// UsageStats 今日消耗统计。
type UsageStats struct {
AIRecommend int64 // 今日AI推荐消耗次数
ClosetShare int64 // 今日衣柜分享消耗次数
OutfitShare int64 // 今日社区分享消耗次数
}
// SocialStats 内容互动统计。
type SocialStats struct {
CosImageCount int64 // COS总图数
LikeCount int64 // 点赞数
CollectCount int64 // 收藏数
}
// UsageStatsGet 聚合今日消耗次数(AI推荐/衣柜分享/社区分享,按 stat_date 单次求和)。
func (d *StatsDao) UsageStatsGet(ctx context.Context, statDate int64) (*UsageStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.UsageStatsGet")
defer span.End()
s := &UsageStats{}
row := struct {
AIRecommend int64
ClosetShare int64
OutfitShare int64
}{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
Where("stat_date = ?", statDate).
Select("COALESCE(SUM(ai_recommend_count), 0) AS ai_recommend, COALESCE(SUM(closet_share_count), 0) AS closet_share, COALESCE(SUM(outfit_share_count), 0) AS outfit_share").
Scan(&row).Error; err != nil {
span.RecordError(err)
return nil, err
}
s.AIRecommend = row.AIRecommend
s.ClosetShare = row.ClosetShare
s.OutfitShare = row.OutfitShare
return s, nil
}
// SocialStatsGet 聚合内容互动统计(COS 总图数/点赞数/收藏数)。
func (d *StatsDao) SocialStatsGet(ctx context.Context) (*SocialStats, error) {
ctx, span := global.Tracer.Start(ctx, "mysqldao.SocialStatsGet")
defer span.End()
s := &SocialStats{}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
Where("processed_image <> ''").
Count(&s.CosImageCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
Where("status = ?", int8(1)).
Count(&s.LikeCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
Where("status = ?", int8(1)).
Count(&s.CollectCount).Error; err != nil {
span.RecordError(err)
return nil, err
}
return s, nil
}