diff --git a/.codebuddy/plans/manage-backend-sync-with-business_535c9ab0.md b/.codebuddy/plans/manage-backend-sync-with-business_535c9ab0.md new file mode 100644 index 0000000..7d39932 --- /dev/null +++ b/.codebuddy/plans/manage-backend-sync-with-business_535c9ab0.md @@ -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_)` 与业务侧到期失效任务按 `ref_id` 关联的习惯一致;**回收流水必须使用不同的 order_no**(如 `revoke_`),否则与同一凭证的发放流水(同为 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: 输出完整的引用清单,确保后端字段重命名与前端类型调整不遗漏任何调用点,避免出现未被察觉的契约断裂 \ No newline at end of file diff --git a/README.md b/README.md index b41abde..75c8f19 100644 --- a/README.md +++ b/README.md @@ -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_`、`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_`、`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_`,避免撞 `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:`),要求两者共用同一 Redis 实例与 DB。 - **管理员会话**:access/refresh 双 token 会话写 Redis;禁用/删除管理员会失效其全部会话;JWTAuth 每次请求回查数据库状态,禁用立即生效(Redis 故障时 fail-open,但 DB 回查兜底拦截禁用账号)。 diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 13740e8..12b6f8b 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -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 段保持一致,否则图片签名校验失败。 diff --git a/internal/dto/dashboard.go b/internal/dto/dashboard.go index e423bf8..de88723 100644 --- a/internal/dto/dashboard.go +++ b/internal/dto/dashboard.go @@ -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"` } diff --git a/internal/dto/order.go b/internal/dto/order.go index 18e0f94..3f98722 100644 --- a/internal/dto/order.go +++ b/internal/dto/order.go @@ -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"` diff --git a/internal/dto/user.go b/internal/dto/user.go index 850449a..6b75833 100644 --- a/internal/dto/user.go +++ b/internal/dto/user.go @@ -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"` diff --git a/internal/mysqldao/quota.go b/internal/mysqldao/quota.go index 0221a97..93adb2a 100644 --- a/internal/mysqldao/quota.go +++ b/internal/mysqldao/quota.go @@ -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 +} diff --git a/internal/mysqldao/stats.go b/internal/mysqldao/stats.go index cec7a62..1a20600 100644 --- a/internal/mysqldao/stats.go +++ b/internal/mysqldao/stats.go @@ -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{} diff --git a/internal/mysqlmodel/business.go b/internal/mysqlmodel/business.go index 6af9e97..c1d41e2 100644 --- a/internal/mysqlmodel/business.go +++ b/internal/mysqlmodel/business.go @@ -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=已取消 diff --git a/internal/service/constants.go b/internal/service/constants.go index 6c2978d..f5ac158 100644 --- a/internal/service/constants.go +++ b/internal/service/constants.go @@ -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 配置段未配置时使用)。 diff --git a/internal/service/dashboard.go b/internal/service/dashboard.go index e31791c..b37f1a0 100644 --- a/internal/service/dashboard.go +++ b/internal/service/dashboard.go @@ -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, diff --git a/internal/service/order.go b/internal/service/order.go index 858d42b..412030d 100644 --- a/internal/service/order.go +++ b/internal/service/order.go @@ -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, diff --git a/internal/service/user.go b/internal/service/user.go index c25f685..ff7833a 100644 --- a/internal/service/user.go +++ b/internal/service/user.go @@ -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