仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
516 lines
15 KiB
Go
516 lines
15 KiB
Go
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
|
||
}
|