feat(backend): 对齐业务服务额度与内容治理口径
仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
---
|
||||
name: manage-backend-sync-with-business
|
||||
overview: 将管理后台 /root/zt-closet-manage-backend 与业务服务 /root/zt-closet-backend 的最新改动对齐:补齐额度枚举(source=4、change_type=7)与订单两列、按「封禁为主」改造仪表盘审核指标、人工发放/回收额度补写流水、对齐免费额度配置值,并同步前端仪表盘字段。
|
||||
todos:
|
||||
- id: dashboard-alignment
|
||||
content: 用 [skill:lsp-code-analysis] 定位字段引用后,改 stats.go、dto/dashboard.go、service/dashboard.go,待办与穿搭概况改为公开/封禁口径
|
||||
status: completed
|
||||
- id: frontend-sync
|
||||
content: 同步前端 types/dashboard.ts 与 DashboardView.vue:待办改三项,穿搭饼图改为公开/私密/已封禁
|
||||
status: completed
|
||||
dependencies:
|
||||
- dashboard-alignment
|
||||
- id: enum-and-order-fields
|
||||
content: 补齐枚举与订单列:constants.go 加 source=4、change_type=7,模型与 DTO 补 order 两列快照及映射
|
||||
status: completed
|
||||
- id: quota-flow-logs
|
||||
content: 人工发放与凭证回收在同一事务内补写额度流水,回收流水使用独立 order_no 避免撞唯一索引
|
||||
status: completed
|
||||
- id: config-and-readme
|
||||
content: 对齐 Quota 配置值为 OutfitShare=3、ClosetShare=0,更新 README 说明审核口径、枚举、流水与生产配置同步
|
||||
status: completed
|
||||
- id: verify
|
||||
content: 后端 gofmt、go build、go vet 全通过,前端执行构建与类型检查通过,核对无遗留引用
|
||||
status: completed
|
||||
dependencies:
|
||||
- dashboard-alignment
|
||||
- frontend-sync
|
||||
- enum-and-order-fields
|
||||
- quota-flow-logs
|
||||
- config-and-readme
|
||||
---
|
||||
|
||||
## 需求概述
|
||||
|
||||
业务服务已更新为「用户公开穿搭即自动过审」等内容治理与额度体系新实现,管理后台存在口径滞后。本次需让管理后台与业务服务最新实现对齐:修正已失效的仪表盘审核口径(改为以封禁为主)、补齐新增枚举与订单快照字段、让管理员人工发放与回收额度产生额度流水,并对齐免费额度配置值。
|
||||
|
||||
## 核心功能
|
||||
|
||||
- 仪表盘待办区不再展示恒为 0 的「待审核穿搭」,改为展示「公开穿搭总数」与「已封禁穿搭数」;穿搭内容概况图由「待审核/已通过/未通过」改为「公开/私密/已封禁」,管理后台前端同步展示。
|
||||
- 内容治理以封禁为主:审核通过/驳回接口保留但降级为兼容用途(实际已无待审数据);封禁后内容立即从小程序社区隐藏,且用户自行取消公开再重新公开也无法恢复可见。
|
||||
- 用户额度凭证的来源标识新增「体验会员赠送」,额度流水新增「到期失效」类型,用户详情与文档能正确解释这些取值,不再出现未知来源。
|
||||
- 订单列表与详情新增展示「商品类型快照」与「额度有效期天数快照」,便于人工退款与权益核对。
|
||||
- 管理员人工发放额度、回收额度时同步产生一条「人工调整」额度流水,小程序端额度明细能看到对应的增加与扣减记录,不再出现只有到期失效扣减、没有发放来源的账实不符。
|
||||
- 免费额度配置值与管理后台展示口径对齐业务服务实际配置(社区分享 3、衣柜分享 0),用户详情页每日额度显示不再偏大。
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 后端:Go 1.26 + Gin + GORM + MySQL + go-redis + viper + OpenTelemetry(沿用现有四层架构,不新增任何依赖)
|
||||
- 前端:Vue3 + TypeScript + Element Plus + ECharts(沿用现有工程与图表组件)
|
||||
- 数据库:与业务服务共用同一 MySQL 库,管理后台只读写业务表、不做任何业务表 DDL
|
||||
|
||||
## 实现方案
|
||||
|
||||
按「数据口径优先、展示跟随、文档收尾」推进,全部改动为存量文件修改,无新增模块。
|
||||
|
||||
1. **仪表盘口径对齐(封禁为主)**:删除不可达的 `is_public=2 AND audit_status=1` 统计,待办改为两个有效指标——公开穿搭总数(`is_public=2`)与已封禁穿搭(`status=2`);穿搭内容概况由 `audit_status` 分组改为 `is_public` 分组(公开/私密)并保留封禁数。选择该口径而非仅修统计条件,是因为业务侧公开动作在同一事务内写入 `is_public=2` 与 `audit_status=2`,审核已不再是公开的前置环节,封禁才是唯一有效治理手段。
|
||||
2. **枚举与镜像补齐**:按业务侧常量新增 `QuotaGrantSourceTrial=4`、`QuotaChangeTypeExpire=7`,同步补齐 DTO 与模型注释;Order 模型补 `GoodsType`、`QuotaValidDays` 两列快照并在订单 DTO 与映射函数中透出。
|
||||
3. **补写人工额度流水**:在人工发放与凭证回收的既有事务内各写一条 `quota_flow_logs` 记录(`change_type=5` 人工调整、`change_target=1` 调整),字段语义与唯一索引规则完全照搬业务侧实现,使业务侧到期失效任务补记的流水能与发放流水形成闭环。
|
||||
4. **配置对齐**:仅修改显式 YAML 值(示例模板与本地配置),代码内兜底常量保持与业务服务兜底一致不动;生产机配置不随代码发布,README 明确提示需手工同步。
|
||||
5. **前端同步**:后端字段变更后前端需同步,否则待办区仍显示「待审核穿搭」空值、饼图标签失真。
|
||||
|
||||
## 实现注意事项
|
||||
|
||||
- **流水唯一索引**:`quota_flow_logs` 唯一索引为 `(order_no, quota_type, change_type)`。发放流水沿用 `order_no = ref_id (manual_<uuid>)` 与业务侧到期失效任务按 `ref_id` 关联的习惯一致;**回收流水必须使用不同的 order_no**(如 `revoke_<uuid>`),否则与同一凭证的发放流水(同为 change_type=5)撞唯一索引导致写入失败。
|
||||
- **事务内取数**:发放/回收的 `old_value`/`new_value` 必须基于同一事务连接读取调整前后的有效额度,不能读全局引擎(会读到事务外的旧值)。优先复用用户详情已有的额度计算逻辑,必要时抽取为可传入事务句柄的内部函数;发放场景亦可按「新增量」直接推算。
|
||||
- **流水失败即整体回滚**:流水写入必须与凭证写入同事务,避免出现「凭证已发但无流水」的新一轮账实不符。
|
||||
- **配置兜底值不动**:只改 `configs/config.example.yaml` 与 `configs/config.yaml` 的显式值,`internal/service/constants.go` 中的兜底常量保持不变(与业务服务兜底值一致,实施时复核业务侧确认)。
|
||||
- **生产机配置需手工同步**:`configs/config.yaml` 被 .gitignore 忽略,部署走挂载,发布后需手工改生产机文件,README 必须写明,否则线上展示仍偏大。
|
||||
- **不要顺带统一 Redis 前缀**:管理后台全局前缀是 `ztclosetadmin:`,通知未读计数必须继续使用独立的 `ztcloset:` 前缀(已与业务服务一致),本次不涉及。
|
||||
- **不要镜像 `clothes.original_image_path`**:该列是业务服务本地路径,同步进模型或 DTO 会泄露服务器路径,保持现状。
|
||||
- **不补未镜像表**:`closet_share_items`、`outfit_shares`、`benefit_grant_tasks` 等 10 张表按用户决定本次不补。
|
||||
- **前端引用需先全量检索**:改前端类型定义前先检索仓库内对 `todo.outfit_pending_audit`、`content.outfit` 的全部引用,避免漏改导致类型检查失败。
|
||||
- **仪表盘统计开销**:待办新增一次 `status=2` 计数,`outfits` 表该列无独立前导索引,属可接受的全表计数;若后续穿搭量级变大,可复用内容概况已有的封禁统计结果,避免重复查询。
|
||||
|
||||
## 架构设计
|
||||
|
||||
沿用现有分层,不引入新模式。改动链路为:业务服务写入的业务表 → 管理后台 `mysqldao` 聚合/读写 → `service` 组装口径 → `dto` 输出 → 管理后台前端渲染。
|
||||
|
||||
- 仪表盘:`mysqldao/stats.go`(聚合)→ `service/dashboard.go`(映射)→ `dto/dashboard.go`(契约)→ 前端 `types/dashboard.ts` + `DashboardView.vue`
|
||||
- 订单:`mysqlmodel/business.go`(镜像列)→ `service/order.go`(映射)→ `dto/order.go`(契约)
|
||||
- 额度:`service/user.go`(事务编排)→ `mysqldao/user.go`(新增流水写入)→ `mysqlmodel`(流水模型,已有)
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
zt-closet-manage-backend/
|
||||
├── internal/
|
||||
│ ├── mysqldao/
|
||||
│ │ ├── stats.go # [MODIFY] 待办统计去掉不可达条件,改为 is_public=2 与 status=2 两次计数;
|
||||
│ │ │ # 穿搭内容概况由 audit_status 分组改为 is_public 分组,内部结构体的
|
||||
│ │ │ # OutfitPending/Passed/Rejected 替换为 OutfitPublic/OutfitPrivate,保留封禁计数
|
||||
│ │ └── user.go # [MODIFY] 新增额度流水写入方法(插入 quota_flow_logs),
|
||||
│ │ # 并新增/抽取可在事务连接上计算某用户某额度类型有效额度的读方法
|
||||
│ ├── dto/
|
||||
│ │ ├── dashboard.go # [MODIFY] DashboardTodo 去掉 OutfitPendingAudit,新增 OutfitPublicTotal、
|
||||
│ │ │ # OutfitBanned;DashboardOutfitStats 改为 Total/Public/Private/Banned
|
||||
│ │ ├── order.go # [MODIFY] OrderItem 新增 goods_type、quota_valid_days 两个 JSON 字段
|
||||
│ │ └── user.go # [MODIFY] UserGrantItem.Source 注释补 4=体验会员赠送
|
||||
│ ├── mysqlmodel/
|
||||
│ │ └── business.go # [MODIFY] Order 模型补 GoodsType、QuotaValidDays 两列(与业务表列名严格一致);
|
||||
│ │ # QuotaGrant.Source 注释补 4;QuotaFlowLog.ChangeType 注释补 7
|
||||
│ └── service/
|
||||
│ ├── constants.go # [MODIFY] 新增 QuotaGrantSourceTrial=4、QuotaChangeTypeExpire=7,
|
||||
│ │ # 并对齐业务侧注释(兜底额度常量保持不变)
|
||||
│ ├── dashboard.go # [MODIFY] 按新字段结构映射待办与穿搭概况
|
||||
│ ├── order.go # [MODIFY] buildOrderItem 透出 goods_type、quota_valid_days
|
||||
│ └── user.go # [MODIFY] 人工发放、凭证回收在同一事务内补写额度流水
|
||||
│ # (发放 change_num 为增量,回收为负的凭证数量;
|
||||
│ # 回收的 order_no 不得与发放相同)
|
||||
├── configs/
|
||||
│ ├── config.example.yaml # [MODIFY] Quota 段 OutfitShare 10 改 3、ClosetShare 5 改 0
|
||||
│ └── config.yaml # [MODIFY] 同上(本地实际配置,已被 .gitignore 忽略)
|
||||
└── README.md # [MODIFY] 说明公开即自动过审、治理靠封禁(且封禁不可被用户绕过)、
|
||||
# 仪表盘新字段含义、source=4 与 change_type=7、人工发放/回收会写流水、
|
||||
# 免费额度值对齐说明与生产机配置需手工同步的提示
|
||||
|
||||
zt-closet-manage-frontend/
|
||||
├── src/types/dashboard.ts # [MODIFY] 待办类型去掉 outfit_pending_audit、新增 outfit_public_total 与
|
||||
│ # outfit_banned;穿搭概况类型改为 public/private/banned
|
||||
└── src/views/dashboard/
|
||||
└── DashboardView.vue # [MODIFY] 待办区改为「待处理退款 / 公开穿搭 / 已封禁」三项;
|
||||
# 穿搭饼图数据源改为公开/私密/已封禁并更新图例与配色
|
||||
```
|
||||
|
||||
## 关键代码结构
|
||||
|
||||
仪表盘契约(改后):
|
||||
|
||||
```
|
||||
// DashboardTodo 待办与内容概览。
|
||||
type DashboardTodo struct {
|
||||
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||
OutfitPublicTotal int64 `json:"outfit_public_total"` // 公开穿搭总数(is_public=2)
|
||||
OutfitBanned int64 `json:"outfit_banned"` // 已封禁穿搭(status=2)
|
||||
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||
}
|
||||
|
||||
// DashboardOutfitStats 穿搭内容概况。
|
||||
type DashboardOutfitStats struct {
|
||||
Total int64 `json:"total"` // 穿搭总数
|
||||
Public int64 `json:"public"` // 公开(is_public=2)
|
||||
Private int64 `json:"private"` // 私密(is_public=1)
|
||||
Banned int64 `json:"banned"` // 已封禁(status=2)
|
||||
}
|
||||
```
|
||||
|
||||
额度流水写入(新增 DAO 方法签名,实现照搬业务侧字段语义):
|
||||
|
||||
```
|
||||
// QuotaFlowLogCreate 写入一条额度流水(调用方需传入事务句柄,保证与凭证变更同事务)。
|
||||
func (d *UserDao) QuotaFlowLogCreate(ctx context.Context, tx *gorm.DB, flow *mysqlmodel.QuotaFlowLog) error
|
||||
```
|
||||
|
||||
## Agent Extensions
|
||||
|
||||
### Skill
|
||||
|
||||
- **lsp-code-analysis**
|
||||
- Purpose: 在修改仪表盘与订单相关结构体字段前,做语义级影响分析——定位 `DashboardTodo`、`DashboardOutfitStats`、`OrderItem`、`UserGrantItem` 等类型的全部定义与引用位置,并在前端仓库定位 `outfit_pending_audit`、`content.outfit` 的所有使用点
|
||||
- Expected outcome: 输出完整的引用清单,确保后端字段重命名与前端类型调整不遗漏任何调用点,避免出现未被察觉的契约断裂
|
||||
@@ -5,11 +5,11 @@
|
||||
## 核心功能
|
||||
|
||||
- **管理员账号**:账号密码登录(bcrypt 存储)、JWT 双 token(access/refresh)签发与刷新、登出、修改密码;超级管理员可增删改普通管理员、禁用/启用账号;初始超级管理员由配置在启动时幂等创建;禁用/删除账号的会话立即失效。
|
||||
- **仪表盘统计**:用户总数/今日新增/各状态分布、订单总数/各状态分布、累计与今日营收、累计退款、待处理退款数、待审核穿搭数、上架商品数,一次接口聚合返回。
|
||||
- **用户管理**:分页搜索(昵称/手机号/OpenID/状态)、详情(含永久额度 + 每日额度 + 额度凭证列表)、封禁/解封(临时按天/永久,写原因与期限)、额度发放(有有效期额度凭证)、额度凭证回收、VIP 人工调整(事务 + 人工调整流水)。
|
||||
- **仪表盘统计**:用户总数/今日新增/各状态分布、订单总数/各状态分布、累计与今日营收、累计退款、待处理退款数、公开展示中的穿搭数、已封禁穿搭数、上架商品数,一次接口聚合返回。
|
||||
- **用户管理**:分页搜索(昵称/手机号/OpenID/状态)、详情(含永久额度 + 每日额度 + 额度凭证列表)、封禁/解封(临时按天/永久,写原因与期限)、额度发放(有有效期额度凭证,同事务写人工调整流水)、额度凭证回收(同事务写人工调整流水)、VIP 人工调整(事务 + 人工调整流水)。
|
||||
- **商品管理**:分页列表(含下架)、新增、编辑(含 `valid_days` 额度有效期天数)、上下架、软删除;支持登记/修改微信虚拟支付道具 ID 与发布状态(仅「未发布/已发布」两态,已发布必须填道具 ID,道具 ID 全局唯一),运营无需再手工改库。**运营约定**:道具在微信后台手动创建;改价后必须同步微信侧道具价格,否则用户付款后发货金额校验会拒绝(拿不到权益)。
|
||||
- **订单与退款管理**:订单列表/详情(含虚拟支付 pay_channel=2 与 XPay 平台/环境/道具字段);退款申请审核——通过置「退款中」(实际打款由运营在微信商户平台手动完成)、驳回置「失败」并记录原因。
|
||||
- **内容审核**:公开穿搭列表/详情(含衣物明细)、审核通过/驳回(写 `outfit_audit_logs`)、封禁/解封;衣物列表/详情;衣柜分享列表、封禁/解封。
|
||||
- **订单与退款管理**:订单列表/详情(含商品类型与额度有效期天数快照、虚拟支付 pay_channel=2 与 XPay 平台/环境/道具字段);退款申请审核——通过置「退款中」(实际打款由运营在微信商户平台手动完成)、驳回置「失败」并记录原因。
|
||||
- **内容审核**:公开穿搭列表/详情(含衣物明细)、封禁/解封(**主要治理手段**)、审核通过/驳回(保留但已无待审数据,见「关键业务规则」);衣物列表/详情;衣柜分享列表、封禁/解封。
|
||||
- **通知推送**:单用户系统通知、全量广播(分批写入 `user_notifications` + 同步 Redis 未读计数,小程序端实时可见)。
|
||||
|
||||
## 技术栈
|
||||
@@ -101,7 +101,7 @@ curl http://localhost:8081/ping
|
||||
| `OTel` | OpenTelemetry | `Enabled`、`ServiceName`、`ExporterEndpoint`(OTLP gRPC)、`Insecure`、`SamplerRatio` |
|
||||
| `JWT` | 鉴权 | `SecretKey`(**必填**)、`Issuer`、`AccessTokenExpire`、`RefreshTokenExpire` |
|
||||
| `Admin` | 初始超管 | `InitialUsername`、`InitialPassword`(仅首次创建时使用) |
|
||||
| `Quota` | 免费额度底值 | `Cos`/`AI`/`OutfitShare`/`ClosetShare`(仅用于展示「总额度」,须与业务服务 `UserDefaultQuota` 免费值一致,未配回落 20/3/10/5) |
|
||||
| `Quota` | 免费额度底值 | `Cos`/`AI`/`OutfitShare`/`ClosetShare`(仅用于展示「总额度」,**须与业务服务 `UserDefaultQuota` 免费值一致**,当前对齐为 20/3/3/0;未配置则回落内置默认 20/3/10/5)。**该段不随代码发布**:生产机配置文件需手工同步,否则线上「每日额度」展示会与小程序端不一致 |
|
||||
| `Cos` | CDN 下载 | `CosDownloadDomain`、`CdnDownloadSecretKey`(用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL,须与业务服务 Cos 段一致) |
|
||||
|
||||
## API 概览
|
||||
@@ -134,7 +134,7 @@ curl http://localhost:8081/ping
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| GET | `/dashboard/summary` | 核心指标聚合 |
|
||||
| GET | `/dashboard/summary` | 核心指标聚合(`todo` 为:`refund_pending` 待处理退款、`outfit_public_total` 公开展示中的穿搭数、`outfit_banned` 已封禁穿搭数、`goods_on_shelf` 上架商品数;`content.outfit` 为公开/私密/已封禁分布) |
|
||||
|
||||
**用户管理**(路径参数 `:uuid` 为用户 UUID)
|
||||
|
||||
@@ -144,9 +144,9 @@ curl http://localhost:8081/ping
|
||||
| GET | `/users/:uuid` | 用户详情(含永久额度 + 每日额度 + 额度凭证列表 `grants`) |
|
||||
| PUT | `/users/:uuid/ban` | 封禁(json:ban_type 1临时/2永久、ban_days、reason) |
|
||||
| PUT | `/users/:uuid/unban` | 解封 |
|
||||
| PUT | `/users/:uuid/quota` | 额度发放(json:quota_type 1上传/2AI/3社区/4衣柜、amount、expire_days) |
|
||||
| PUT | `/users/:uuid/quota` | 额度发放(json:quota_type 1上传/2AI/3社区/4衣柜、amount、expire_days);同事务写 `quota_flow_logs` 人工调整流水 |
|
||||
| PUT | `/users/:uuid/vip` | VIP 人工调整(json:vip_type、vip_days、reason) |
|
||||
| DELETE | `/users/:uuid/grants/:id` | 回收额度凭证(软删) |
|
||||
| DELETE | `/users/:uuid/grants/:id` | 回收额度凭证(软删 + 归属校验);同事务写 `quota_flow_logs` 人工调整流水 |
|
||||
|
||||
**商品管理**
|
||||
|
||||
@@ -174,8 +174,8 @@ curl http://localhost:8081/ping
|
||||
| --- | --- | --- |
|
||||
| GET | `/outfits` | 穿搭列表(query:page/page_size/user_id/is_public/audit_status/status/keyword) |
|
||||
| GET | `/outfits/:uuid` | 穿搭详情(含衣物明细) |
|
||||
| POST | `/outfits/:uuid/audit` | 审核(json:result 1通过/2驳回、reason) |
|
||||
| POST | `/outfits/:uuid/ban` | 封禁穿搭 |
|
||||
| POST | `/outfits/:uuid/audit` | 审核(json:result 1通过/2驳回、reason)。**兼容保留**:业务侧公开即自动过审,实际已无 `audit_status=1` 的数据可操作,仅允许操作待审记录,否则返回状态不允许 |
|
||||
| POST | `/outfits/:uuid/ban` | 封禁穿搭(**主要治理手段**) |
|
||||
| POST | `/outfits/:uuid/unban` | 解封穿搭 |
|
||||
| GET | `/clothes` | 衣物列表(query:page/page_size/user_id/image_process_status/category_main/keyword) |
|
||||
| GET | `/clothes/:uuid` | 衣物详情 |
|
||||
@@ -202,10 +202,11 @@ curl http://localhost:8081/ping
|
||||
## 关键业务规则
|
||||
|
||||
- **退款审核状态机**:仅允许 `0 待处理 → 1 退款中`(通过)与 `0 待处理 → 3 失败`(驳回)。实际微信打款由运营在微信商户平台手动完成,业务服务的退款回调负责后续「成功(2)」流转与权益回收。
|
||||
- **额度体系**(与业务服务一致):`user_quotas` 仅存 Cos 永久额度;AI/社区/衣柜 为每日额度;有有效期赠送额度走 `quota_grants` 凭证(source:1 会员包 / 2 加量包 / 3 广告激励)。管理后台「额度发放」写入 `quota_grants`(source=2 加量包、`ref_id=manual_<uuid>`、`expire_time=now+expire_days*86400`):Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量;「凭证回收」软删除对应凭证。有效额度 = 免费配置值(`Quota` 段)+ 有效凭证,每日已用读 `user_daily_usages`。
|
||||
- **额度体系**(与业务服务一致):`user_quotas` 仅存 Cos 永久额度;AI/社区/衣柜 为每日额度;有有效期赠送额度走 `quota_grants` 凭证(source:1 会员包 / 2 加量包 / 3 广告激励 / 4 体验会员赠送)。管理后台「额度发放」写入 `quota_grants`(source=2 加量包、`ref_id=manual_<uuid>`、`expire_time=now+expire_days*86400`):Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量;「凭证回收」软删除对应凭证,仅「未用完的部分」失效(`Amount-Used`)。有效额度 = 免费配置值(`Quota` 段,须与业务服务 `UserDefaultQuota` 一致)+ 有效凭证,每日已用读 `user_daily_usages`。
|
||||
- **额度流水闭环**:管理后台的「额度发放」「凭证回收」在**同一事务内**写 `quota_flow_logs`(`change_type=5` 人工调整、`change_target=1`),流水的 `old_value`/`new_value` 取事务内真实剩余额度,保证「变更前 + 变更量 = 变更后」;发放流水的 `order_no` 用凭证 `ref_id`(与业务侧到期失效流水按 `ref_id` 关联的口径一致),回收流水使用独立的 `revoke_<uuid>`,避免撞 `uk_quota_flow_idem(order_no, quota_type, change_type)` 唯一索引。
|
||||
- **VIP 人工调整**:叠加语义(`vip_expire = max(当前到期, now) + vip_days*86400`),`vip_days=0` 表示取消会员;事务内更新 `users` + 写 `vip_flow_logs`(`change_type=5`、`source=4`)。
|
||||
- **用户封禁**:改 `users.status`(2 临时/3 永久)+ 原因与期限,业务服务鉴权中间件每次请求校验状态,**改库即生效**。
|
||||
- **穿搭可见性**:`is_public=2` 且 `audit_status=2` 且 `status=1` 三者同时满足才对外可见;审核驳回置 `audit_status=3`、封禁置 `status=2`。
|
||||
- **穿搭可见性与治理**:业务服务侧用户「公开穿搭」时会在同一次更新中写入 `is_public=2` 与 `audit_status=2`(**公开即自动过审**),因此本管理后台的「审核通过/驳回」已无待审数据(接口保留仅为兼容),仪表盘也不再输出「待审核穿搭」,改为输出「公开穿搭总数(不含已封禁)」与「已封禁穿搭数」。对外可见条件为 `is_public=2` 且 `audit_status=2` 且 `status=1`,**内容治理以封禁(`status=2`)为主**:封禁后立即从小程序社区、衣物社区入口与穿搭分享中隐藏,且用户自行「取消公开 → 再次公开」也无法恢复可见(公开动作只改 `is_public`/`audit_status`,不改 `status`)。
|
||||
- **通知未读计数**:管理后台自增业务服务的同一 Redis key(`ztcloset:notification:unread:<user_id>`),要求两者共用同一 Redis 实例与 DB。
|
||||
- **管理员会话**:access/refresh 双 token 会话写 Redis;禁用/删除管理员会失效其全部会话;JWTAuth 每次请求回查数据库状态,禁用立即生效(Redis 故障时 fail-open,但 DB 回查兜底拦截禁用账号)。
|
||||
|
||||
|
||||
@@ -55,12 +55,13 @@ Admin:
|
||||
InitialUsername: admin # 必填:留空则跳过初始账号创建
|
||||
InitialPassword: Admin@123456 # 初始密码(明文仅存在于配置文件中,入库为 bcrypt 哈希)
|
||||
Quota:
|
||||
# 免费额度底值(仅用于管理后台计算并展示「总额度」),必须与业务服务 UserDefaultQuota 免费值保持一致。
|
||||
# 免费额度底值(仅用于管理后台计算并展示「总额度」),必须与业务服务 UserDefaultQuota 免费值保持一致;
|
||||
# 不一致会导致用户详情「每日额度」的 Total/Remain 偏大或偏小。
|
||||
# 删除某项或整段则回落内置默认(Cos 20 / AI 3 / OutfitShare 10 / ClosetShare 5);负数视为非法,同样回落默认。
|
||||
Cos: 20 # 免费上传额度(永久)
|
||||
AI: 3 # 免费 AI 推荐额度(每日)
|
||||
OutfitShare: 10 # 免费社区分享额度(每日)
|
||||
ClosetShare: 5 # 免费衣柜分享额度(每日)
|
||||
OutfitShare: 3 # 免费社区分享额度(每日)
|
||||
ClosetShare: 0 # 免费衣柜分享额度(每日)
|
||||
Cos:
|
||||
# CDN 下载配置:用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL。
|
||||
# 必须与业务服务(zt-closet-backend)configs/config.yaml 的 Cos 段保持一致,否则图片签名校验失败。
|
||||
|
||||
+13
-11
@@ -40,11 +40,12 @@ type DashboardRevenue struct {
|
||||
RefundTotal int64 `json:"refund_total"` // 累计退款
|
||||
}
|
||||
|
||||
// DashboardTodo 待办统计。
|
||||
// DashboardTodo 待办与核心概览。
|
||||
type DashboardTodo struct {
|
||||
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||
OutfitPendingAudit int64 `json:"outfit_pending_audit"` // 待审核穿搭
|
||||
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||
OutfitPublicTotal int64 `json:"outfit_public_total"` // 公开展示中的穿搭数(不含已封禁)
|
||||
OutfitBanned int64 `json:"outfit_banned"` // 已封禁穿搭
|
||||
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||
}
|
||||
|
||||
// ===== 内容审核概况 =====
|
||||
@@ -56,13 +57,14 @@ type DashboardContent struct {
|
||||
Share DashboardShareStats `json:"share"`
|
||||
}
|
||||
|
||||
// DashboardOutfitStats 穿搭审核统计。
|
||||
// DashboardOutfitStats 穿搭内容概况。
|
||||
// 业务侧用户公开穿搭时即自动过审,审核不再是公开的前置条件,故不再输出审核状态分布;
|
||||
// 公开/私密按 is_public 统计且均已剔除已封禁,三者在图表中互不重叠。
|
||||
type DashboardOutfitStats struct {
|
||||
Total int64 `json:"total"` // 穿搭总数
|
||||
Pending int64 `json:"pending"` // 待审核
|
||||
Passed int64 `json:"passed"` // 审核通过
|
||||
Rejected int64 `json:"rejected"` // 审核不通过
|
||||
Banned int64 `json:"banned"` // 封禁
|
||||
Total int64 `json:"total"` // 穿搭总数
|
||||
Public int64 `json:"public"` // 公开展示中(不含已封禁)
|
||||
Private int64 `json:"private"` // 私密(不含已封禁)
|
||||
Banned int64 `json:"banned"` // 已封禁
|
||||
}
|
||||
|
||||
// DashboardClothesStats 衣物图片处理统计。
|
||||
@@ -105,7 +107,7 @@ type DashboardGoods struct {
|
||||
|
||||
// GoodsTypeCount 商品类型数量。
|
||||
type GoodsTypeCount struct {
|
||||
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
|
||||
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ type OrderItem struct {
|
||||
OrderStatus int8 `json:"order_status"`
|
||||
TransactionID string `json:"transaction_id"`
|
||||
MemberDays int64 `json:"member_days"`
|
||||
GoodsType int8 `json:"goods_type"` // 商品类型快照 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
|
||||
QuotaValidDays int64 `json:"quota_valid_days"` // 额度有效期天数快照(加量包按此计算赠送有效期;会员包为 0 表示跟随 VIP)
|
||||
UploadQuota int64 `json:"upload_quota"`
|
||||
AIRecommendQuota int64 `json:"ai_recommend_quota"`
|
||||
ClosetShareQuota int64 `json:"closet_share_quota"`
|
||||
|
||||
@@ -47,7 +47,7 @@ type UserGrantItem struct {
|
||||
QuotaType int8 `json:"quota_type"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `json:"amount"` // 赠送总量(Cos=一次性库存;AI/社区/衣柜=有效期内每日加量)
|
||||
Used int64 `json:"used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `json:"source"` // 1=会员包 2=加量包 3=广告激励
|
||||
Source int8 `json:"source"` // 1=会员包 2=加量包 3=广告激励 4=体验会员赠送
|
||||
ExpireTime int64 `json:"expire_time"` // 到期时间(秒,0=跟随 VIP)
|
||||
RefID string `json:"ref_id"` // 关联ID(订单号/广告记录ID/人工发放标识)
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
|
||||
+170
-26
@@ -2,12 +2,26 @@ package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 额度类型(user_quotas.quota_type / quota_grants.quota_type / quota_flow_logs.quota_type),
|
||||
// 取值与业务服务保持一致;mysqldao 不反向依赖 service 常量,故在此独立定义。
|
||||
const (
|
||||
quotaTypeCos int8 = 1 // COS 上传
|
||||
quotaTypeAI int8 = 2 // AI 推荐
|
||||
quotaTypeOutfitShare int8 = 3 // 社区分享
|
||||
quotaTypeClosetShare int8 = 4 // 衣柜分享
|
||||
|
||||
quotaChangeTargetAdjust int8 = 1 // 额度流水 change_target:配额调整
|
||||
quotaChangeTypeManual int8 = 5 // 额度流水 change_type:人工调整(管理后台发放/回收写入)
|
||||
)
|
||||
|
||||
// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。
|
||||
type QuotaGrantDao struct {
|
||||
db *gorm.DB
|
||||
@@ -18,18 +32,6 @@ func NewQuotaGrantDao() *QuotaGrantDao {
|
||||
return &QuotaGrantDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
// QuotaGrantCreate 创建额度凭证(ref_id+quota_type 唯一索引兜底幂等,冲突返回 1062)。
|
||||
func (d *QuotaGrantDao) QuotaGrantCreate(ctx context.Context, grant *mysqlmodel.QuotaGrant) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreate")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).Create(grant).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaGrantListByUserID 查询用户全部凭证(未删除),按 id 倒序。
|
||||
func (d *QuotaGrantDao) QuotaGrantListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.QuotaGrant, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantListByUserID")
|
||||
@@ -56,20 +58,6 @@ func (d *QuotaGrantDao) QuotaGrantGetByID(ctx context.Context, id int64) (*mysql
|
||||
return &grant, nil
|
||||
}
|
||||
|
||||
// QuotaGrantSoftDelete 软删除凭证(按 id + user_id 归属校验)。
|
||||
func (d *QuotaGrantDao) QuotaGrantSoftDelete(ctx context.Context, id, userID int64) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantSoftDelete")
|
||||
defer span.End()
|
||||
|
||||
if err := d.db.WithContext(ctx).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Delete(&mysqlmodel.QuotaGrant{}).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// QuotaGetByUserIDAndType 查询用户某类配额(Cos 永久额度的已用/底值)。
|
||||
func (d *QuotaGrantDao) QuotaGetByUserIDAndType(ctx context.Context, userID int64, quotaType int8) (*mysqlmodel.Quota, error) {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGetByUserIDAndType")
|
||||
@@ -99,3 +87,159 @@ func (d *QuotaGrantDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, us
|
||||
}
|
||||
return &usage, nil
|
||||
}
|
||||
|
||||
// QuotaFlowParams 人工额度调整流水参数。
|
||||
// OldValue/NewValue 语义与业务服务一致:变更前后该类额度的「有效剩余」,满足「变更前 + ChangeNum = 变更后」。
|
||||
type QuotaFlowParams struct {
|
||||
UserID int64 // 用户ID
|
||||
VipExpire int64 // 用户 VIP 到期时间(凭证 expire_time=0 时跟随 VIP)
|
||||
QuotaType int8 // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||
Delta int64 // 本次有效额度变化量(发放为正、回收为负)
|
||||
FreeTotal int64 // 免费额度底值(由 service 依 Quota 配置段计算,DAO 不反向依赖 service 常量)
|
||||
StatDate int64 // 当日统计日期 YYYYMMDD(每日额度已用取数用)
|
||||
OrderNo string // 流水关联号:发放用凭证 ref_id;回收用独立 revoke_ 前缀(避免同凭证撞唯一索引)
|
||||
RelatedID int64 // 关联凭证ID
|
||||
Reason string // 变更原因
|
||||
}
|
||||
|
||||
// QuotaGrantCreateWithFlow 事务内创建额度凭证并写「人工调整」流水(change_type=5)。
|
||||
// 凭证与流水同事务提交,避免出现「凭证已发但无流水」导致小程序额度明细账实不符。
|
||||
func (d *QuotaGrantDao) QuotaGrantCreateWithFlow(ctx context.Context, grant *mysqlmodel.QuotaGrant, p QuotaFlowParams) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantCreateWithFlow")
|
||||
defer span.End()
|
||||
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(grant).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
p.RelatedID = grant.ID
|
||||
// 变更后剩余从事务内取,保证与本次写入一致(发放后新增量已计入)。
|
||||
remainAfter, err := quotaRemainTx(ctx, tx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(buildQuotaFlow(p, remainAfter)).Error
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// QuotaGrantRevokeWithFlow 事务内软删除额度凭证并写「人工调整」流水(change_type=5)。
|
||||
func (d *QuotaGrantDao) QuotaGrantRevokeWithFlow(ctx context.Context, p QuotaFlowParams) error {
|
||||
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantRevokeWithFlow")
|
||||
defer span.End()
|
||||
|
||||
err := d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 按 id + user_id 归属校验,防越权回收他人凭证。
|
||||
res := tx.Where("id = ? AND user_id = ?", p.RelatedID, p.UserID).Delete(&mysqlmodel.QuotaGrant{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return gorm.ErrRecordNotFound
|
||||
}
|
||||
remainAfter, err := quotaRemainTx(ctx, tx, p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(buildQuotaFlow(p, remainAfter)).Error
|
||||
})
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// buildQuotaFlow 依据「变更后有效剩余」反推变更前值,构造人工调整流水,
|
||||
// 保证明细满足「变更前 + 变更量 = 变更后」(与业务服务到期失效流水的构造方式一致)。
|
||||
func buildQuotaFlow(p QuotaFlowParams, remainAfter int64) *mysqlmodel.QuotaFlowLog {
|
||||
return &mysqlmodel.QuotaFlowLog{
|
||||
QuotaFlowUUID: genFlowUUID(),
|
||||
UserID: p.UserID,
|
||||
ChangeTarget: quotaChangeTargetAdjust,
|
||||
ChangeType: quotaChangeTypeManual,
|
||||
ChangeNum: p.Delta,
|
||||
OldValue: remainAfter - p.Delta,
|
||||
NewValue: remainAfter,
|
||||
OrderNo: p.OrderNo,
|
||||
QuotaType: p.QuotaType,
|
||||
RelatedID: p.RelatedID,
|
||||
Reason: p.Reason,
|
||||
}
|
||||
}
|
||||
|
||||
// quotaRemainTx 事务内计算用户某类额度的有效剩余,口径与 service 层用户详情一致:
|
||||
// 有效剩余 = 免费底值 + 有效凭证总量 - 已用量(Cos 为永久累计已用;其余为当日已用),不足则归零。
|
||||
func quotaRemainTx(ctx context.Context, tx *gorm.DB, p QuotaFlowParams) (int64, error) {
|
||||
now := time.Now().Unix()
|
||||
|
||||
// GORM 软删除自动过滤已回收凭证;此处只需再按有效期判断(数量级为用户维度,直接内存过滤)。
|
||||
var grants []mysqlmodel.QuotaGrant
|
||||
if err := tx.WithContext(ctx).
|
||||
Where("user_id = ? AND quota_type = ?", p.UserID, p.QuotaType).
|
||||
Find(&grants).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
var validAmount, validUsed int64
|
||||
for i := range grants {
|
||||
g := &grants[i]
|
||||
if quotaGrantExpired(g.ExpireTime, p.VipExpire, now) {
|
||||
continue
|
||||
}
|
||||
validAmount += g.Amount
|
||||
validUsed += g.Used
|
||||
}
|
||||
|
||||
var used int64
|
||||
if p.QuotaType == quotaTypeCos {
|
||||
used = validUsed
|
||||
var quota mysqlmodel.Quota
|
||||
switch err := tx.WithContext(ctx).
|
||||
Where("user_id = ? AND quota_type = ?", p.UserID, p.QuotaType).
|
||||
First("a).Error; {
|
||||
case err == nil:
|
||||
used += quota.Used
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// 无免费额度记录视为已用 0。
|
||||
default:
|
||||
return 0, err
|
||||
}
|
||||
} else {
|
||||
var usage mysqlmodel.UserDailyUsage
|
||||
switch err := tx.WithContext(ctx).
|
||||
Where("user_id = ? AND stat_date = ?", p.UserID, p.StatDate).
|
||||
First(&usage).Error; {
|
||||
case err == nil:
|
||||
switch p.QuotaType {
|
||||
case quotaTypeAI:
|
||||
used = usage.AIRecommendCount
|
||||
case quotaTypeOutfitShare:
|
||||
used = usage.OutfitShareCount
|
||||
case quotaTypeClosetShare:
|
||||
used = usage.ClosetShareCount
|
||||
}
|
||||
case errors.Is(err, gorm.ErrRecordNotFound):
|
||||
// 当日无使用记录视为已用 0。
|
||||
default:
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
remain := p.FreeTotal + validAmount - used
|
||||
if remain < 0 {
|
||||
remain = 0
|
||||
}
|
||||
return remain, nil
|
||||
}
|
||||
|
||||
// quotaGrantExpired 判断凭证是否失效(与 service.grantExpired 口径一致):
|
||||
// expire_time=0 表示跟随 VIP 到期;其余按自身到期时间判断。
|
||||
func quotaGrantExpired(expireTime, vipExpire, now int64) bool {
|
||||
if expireTime == 0 {
|
||||
return vipExpire <= now
|
||||
}
|
||||
return expireTime <= now
|
||||
}
|
||||
|
||||
+37
-40
@@ -46,10 +46,10 @@ type RevenueStats struct {
|
||||
}
|
||||
|
||||
// TodoStats 待办统计。
|
||||
// 穿搭相关指标(公开展示中数量、已封禁数量)由 ContentStatsGet 一并聚合,此处不重复查询。
|
||||
type TodoStats struct {
|
||||
RefundPending int64 // 待处理退款数
|
||||
OutfitPendingAudit int64 // 待审核穿搭数
|
||||
GoodsOnShelf int64 // 上架商品数
|
||||
RefundPending int64 // 待处理退款数
|
||||
GoodsOnShelf int64 // 上架商品数
|
||||
}
|
||||
|
||||
// UserStatsGet 聚合用户总数与今日新增
|
||||
@@ -169,12 +169,6 @@ func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("is_public = ? AND audit_status = ?", int8(2), int8(1)).
|
||||
Count(&s.OutfitPendingAudit).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 {
|
||||
@@ -184,6 +178,13 @@ func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// outfitDistRow 穿搭 (is_public, status) 组合分布行。
|
||||
type outfitDistRow struct {
|
||||
IsPublic int8
|
||||
Status int8
|
||||
Cnt int64
|
||||
}
|
||||
|
||||
// countByInt8 通用分组计数行(小整型枚举 + 计数)。
|
||||
type countByInt8 struct {
|
||||
K int8
|
||||
@@ -205,20 +206,19 @@ type dateRevenue struct {
|
||||
|
||||
// ContentStats 内容审核统计。
|
||||
type ContentStats struct {
|
||||
OutfitTotal int64
|
||||
OutfitPending int64
|
||||
OutfitPassed int64
|
||||
OutfitRejected int64
|
||||
OutfitBanned int64
|
||||
ClothesTotal int64
|
||||
ClothesProcessing int64
|
||||
ClothesSuccess int64
|
||||
ClothesFailed int64
|
||||
ShareTotal int64
|
||||
ShareNormal int64
|
||||
ShareCancelled int64
|
||||
ShareExpired int64
|
||||
ShareBanned int64
|
||||
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 会员统计。
|
||||
@@ -258,32 +258,29 @@ func (d *StatsDao) ContentStatsGet(ctx context.Context) (*ContentStats, error) {
|
||||
|
||||
s := &ContentStats{}
|
||||
|
||||
// 穿搭:audit_status 分布 + 封禁数
|
||||
outfitRows := []countByInt8{}
|
||||
// 穿搭:一次 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("audit_status AS k, COUNT(*) AS cnt").
|
||||
Group("audit_status").
|
||||
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 r.K {
|
||||
case 1:
|
||||
s.OutfitPending = r.Cnt
|
||||
case 2:
|
||||
s.OutfitPassed = r.Cnt
|
||||
case 3:
|
||||
s.OutfitRejected = 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
|
||||
}
|
||||
}
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||
Where("status = ?", int8(2)).
|
||||
Count(&s.OutfitBanned).Error; err != nil {
|
||||
span.RecordError(err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 衣物:image_process_status 分布(1/2/3 处理中,4 成功,5 失败)
|
||||
clothesRows := []countByInt8{}
|
||||
|
||||
@@ -71,6 +71,8 @@ type Order struct {
|
||||
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
||||
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
||||
GoodsType int8 `gorm:"column:goods_type"` // 商品类型(快照)
|
||||
QuotaValidDays int64 `gorm:"column:quota_valid_days"` // 额度有效期天数(快照)
|
||||
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
|
||||
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
||||
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
||||
@@ -225,7 +227,7 @@ type QuotaGrant struct {
|
||||
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"` // 额度类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||
Amount int64 `gorm:"column:amount"` // 赠送总量
|
||||
Used int64 `gorm:"column:used"` // 已用量(仅 Cos 扣减)
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励
|
||||
Source int8 `gorm:"column:source"` // 来源 1=会员包 2=加量包 3=广告激励 4=体验会员赠送
|
||||
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"` // 到期时间(秒,0 表示跟随 VIP)
|
||||
RefID string `gorm:"column:ref_id;type:varchar(64);uniqueIndex:uk_grant_ref_type,priority:1"` // 关联ID(订单号/广告记录ID)
|
||||
}
|
||||
@@ -254,7 +256,7 @@ type QuotaFlowLog struct {
|
||||
QuotaFlowUUID string `gorm:"column:quota_flow_uuid"` // 配额流水UUID
|
||||
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||
ChangeTarget int8 `gorm:"column:change_target"` // 调整额度类型 1=配额调整 2=使用
|
||||
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收
|
||||
// ChangeType 变更类型 1=购买获赠 2=活动赠送 3=上传消耗 4=删除回退 5=人工调整 6=退款回收 7=到期失效
|
||||
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
|
||||
ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
|
||||
OldValue int64 `gorm:"column:old_value"` // 变更前数值
|
||||
@@ -314,7 +316,7 @@ func (UserNotification) TableName() string {
|
||||
type Like struct {
|
||||
Model
|
||||
// LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
LikeType int8 `gorm:"column:like_type;uniqueIndex:uk_like_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_like_user_target,priority:2"` // 穿搭ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_like_user_target,priority:3"` // 点赞用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已点赞 0=已取消
|
||||
@@ -327,7 +329,7 @@ func (Like) TableName() string {
|
||||
type Collect struct {
|
||||
Model
|
||||
// CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
CollectType int8 `gorm:"column:collect_type;uniqueIndex:uk_collect_user_target,priority:1"`
|
||||
TargetID int64 `gorm:"column:target_id;uniqueIndex:uk_collect_user_target,priority:2"` // 业务目标ID
|
||||
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_collect_user_target,priority:3"` // 收藏用户ID
|
||||
Status int8 `gorm:"column:status"` // 状态 1=已收藏 0=已取消
|
||||
|
||||
@@ -33,14 +33,19 @@ const (
|
||||
QuotaTypeClosetShare int8 = 4 // 衣柜分享
|
||||
|
||||
// 额度凭证来源(quota_grants.source)
|
||||
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
|
||||
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期,续期自动延长)
|
||||
QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
|
||||
QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
|
||||
QuotaGrantSourceTrial int8 = 4 // 体验会员赠送(新用户注册;固定有效期=体验期结束,不随续费延长)
|
||||
|
||||
QuotaChangeTargetAdjust int8 = 1 // 配额调整
|
||||
QuotaChangeTargetUse int8 = 2 // 使用
|
||||
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||
// 额度流水变更类型(quota_flow_logs.change_type)。
|
||||
// 购买获赠(1)/活动赠送(2)/上传消耗(3)/删除回退(4)/退款回收(6)/到期失效(7) 由业务服务写入,
|
||||
// 管理后台仅写入人工调整(5)。
|
||||
QuotaChangeTypeManual int8 = 5 // 人工调整(管理后台发放/回收额度时写入)
|
||||
QuotaChangeTypeExpire int8 = 7 // 到期失效(固定有效期凭证过期,由业务服务定时任务补记)
|
||||
)
|
||||
|
||||
// 免费额度内置兜底值(与业务服务 DefaultQuota* 对齐;仅当 Quota 配置段未配置时使用)。
|
||||
|
||||
@@ -92,17 +92,18 @@ func (s *DashboardService) Summary(ctx context.Context) (*dto.DashboardResponse,
|
||||
RefundTotal: revenueStats.RefundTotal,
|
||||
},
|
||||
Todo: dto.DashboardTodo{
|
||||
RefundPending: todoStats.RefundPending,
|
||||
OutfitPendingAudit: todoStats.OutfitPendingAudit,
|
||||
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||
RefundPending: todoStats.RefundPending,
|
||||
// 穿搭口径复用内容概况聚合结果,避免对 outfits 表重复计数。
|
||||
OutfitPublicTotal: contentStats.OutfitPublic,
|
||||
OutfitBanned: contentStats.OutfitBanned,
|
||||
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||
},
|
||||
Content: dto.DashboardContent{
|
||||
Outfit: dto.DashboardOutfitStats{
|
||||
Total: contentStats.OutfitTotal,
|
||||
Pending: contentStats.OutfitPending,
|
||||
Passed: contentStats.OutfitPassed,
|
||||
Rejected: contentStats.OutfitRejected,
|
||||
Banned: contentStats.OutfitBanned,
|
||||
Total: contentStats.OutfitTotal,
|
||||
Public: contentStats.OutfitPublic,
|
||||
Private: contentStats.OutfitPrivate,
|
||||
Banned: contentStats.OutfitBanned,
|
||||
},
|
||||
Clothes: dto.DashboardClothesStats{
|
||||
Total: contentStats.ClothesTotal,
|
||||
|
||||
@@ -164,6 +164,8 @@ func buildOrderItem(o *mysqlmodel.Order) dto.OrderItem {
|
||||
OrderStatus: o.OrderStatus,
|
||||
TransactionID: o.TransactionID,
|
||||
MemberDays: o.MemberDays,
|
||||
GoodsType: o.GoodsType,
|
||||
QuotaValidDays: o.QuotaValidDays,
|
||||
UploadQuota: o.CosQuota,
|
||||
AIRecommendQuota: o.AIRecommendQuota,
|
||||
ClosetShareQuota: o.ClosetShareQuota,
|
||||
|
||||
@@ -233,7 +233,18 @@ func (s *UserService) QuotaAdjust(ctx context.Context, adminID int64, userUUID s
|
||||
grant.CreatedBy = adminID
|
||||
grant.UpdatedBy = adminID
|
||||
|
||||
if err := s.quotaGrantDao.QuotaGrantCreate(ctx, grant); err != nil {
|
||||
// 凭证与「人工调整」流水同事务写入:小程序额度明细中能看到对应的增加记录,
|
||||
// 避免业务侧到期失效任务补记了扣减、却查不到发放来源的账实不符。
|
||||
if err := s.quotaGrantDao.QuotaGrantCreateWithFlow(ctx, grant, mysqldao.QuotaFlowParams{
|
||||
UserID: user.ID,
|
||||
VipExpire: user.VIPExpire,
|
||||
QuotaType: request.QuotaType,
|
||||
Delta: request.Amount,
|
||||
FreeTotal: defaultQuotaValue(request.QuotaType),
|
||||
StatDate: todayStatDate(),
|
||||
OrderNo: grant.RefID, // 与业务侧到期失效流水按 ref_id 关联的口径保持一致
|
||||
Reason: "管理员发放额度凭证",
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
@@ -252,14 +263,36 @@ func (s *UserService) GrantRevoke(ctx context.Context, adminID int64, userUUID s
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
|
||||
if _, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID); err != nil {
|
||||
grant, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
return errcode.UserGrantNotFound
|
||||
}
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
// 归属校验:凭证必须属于该用户,防止越权回收他人凭证。
|
||||
if grant.UserID != user.ID {
|
||||
return errcode.UserGrantNotFound
|
||||
}
|
||||
|
||||
if err := s.quotaGrantDao.QuotaGrantSoftDelete(ctx, grantID, user.ID); err != nil {
|
||||
// 回收使「未用完的部分」失效:Cos 为 Amount-Used,每日额度 Used 恒为 0(口径与业务侧到期失效一致)。
|
||||
revoke := grant.Amount - grant.Used
|
||||
if revoke < 0 {
|
||||
revoke = 0
|
||||
}
|
||||
// 回收流水使用独立 order_no:同一凭证的发放流水同为 change_type=5,
|
||||
// 复用 ref_id 会撞 uk_quota_flow_idem(order_no, quota_type, change_type) 唯一索引。
|
||||
if err := s.quotaGrantDao.QuotaGrantRevokeWithFlow(ctx, mysqldao.QuotaFlowParams{
|
||||
UserID: user.ID,
|
||||
VipExpire: user.VIPExpire,
|
||||
QuotaType: grant.QuotaType,
|
||||
Delta: -revoke,
|
||||
FreeTotal: defaultQuotaValue(grant.QuotaType),
|
||||
StatDate: todayStatDate(),
|
||||
OrderNo: "revoke_" + genUUID(),
|
||||
RelatedID: grant.ID,
|
||||
Reason: "管理员回收额度凭证",
|
||||
}); err != nil {
|
||||
return errcode.DatabaseError
|
||||
}
|
||||
return nil
|
||||
|
||||
Reference in New Issue
Block a user