feat(backend): 对齐业务服务额度与内容治理口径

仪表盘待办与穿搭概况改为公开/私密/已封禁口径,移除恒为零的待审核统计;补齐额度凭证来源 source=4 与流水 change_type=7 枚举及订单商品类型、额度有效期快照字段;管理员发放与回收额度凭证在同一事务内写入人工调整流水,回收流水使用独立 order_no 避免撞唯一索引,并增加凭证归属校验;对齐 Quota 配置免费额度值,同步更新 README 与配置说明。
This commit is contained in:
2026-09-16 02:58:56 +08:00
parent 5d6109bb52
commit 0863234bc5
13 changed files with 460 additions and 110 deletions
@@ -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: 输出完整的引用清单,确保后端字段重命名与前端类型调整不遗漏任何调用点,避免出现未被察觉的契约断裂
+13 -12
View File
@@ -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 回查兜底拦截禁用账号)。
+4 -3
View File
@@ -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
View File
@@ -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"`
}
+2
View File
@@ -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"`
+1 -1
View File
@@ -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
View File
@@ -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(&quota).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
View File
@@ -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{}
+6 -4
View File
@@ -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=已取消
+7 -2
View File
@@ -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 配置段未配置时使用)。
+9 -8
View File
@@ -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,
+2
View File
@@ -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,
+36 -3
View File
@@ -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