first commit
This commit is contained in:
@@ -0,0 +1,230 @@
|
|||||||
|
---
|
||||||
|
name: zt-closet-manage-backend
|
||||||
|
overview: 参考 /root/zt-closet-backend 业务服务的代码风格(Gin+GORM+Redis+viper+JWT+OTel+slog 分层架构),在 /root/zt-closet-manage-backend 从零实现管理后台后端:管理员账号体系、仪表盘统计、用户/商品/订单退款/内容审核/通知推送七大模块,与业务服务共用同一 MySQL 库。
|
||||||
|
todos:
|
||||||
|
- id: setup-skeleton
|
||||||
|
content: 搭建项目骨架:go.mod 依赖裁剪、configs 模板、global、pkg/{setting,app,errcode,lzcwyjwt,tracing}、中间件链、redismodel/redisdao、main.go 初始化与优雅关闭、路由骨架与 .gitignore
|
||||||
|
status: completed
|
||||||
|
- id: data-layer
|
||||||
|
content: 定义数据层:mysqlmodel 引擎与全部业务表模型、admin_users 表、AutoMigrate 与初始超级管理员 seed
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- setup-skeleton
|
||||||
|
- id: admin-module
|
||||||
|
content: 实现管理员账号模块:登录/登出/刷新 token/改密、管理员 CRUD 与超级管理员权限控制
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- data-layer
|
||||||
|
- id: dashboard-module
|
||||||
|
content: 实现仪表盘统计模块:用户/订单/营收/待办等核心指标聚合接口
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- data-layer
|
||||||
|
- id: user-module
|
||||||
|
content: 实现用户管理模块:分页搜索/详情/封禁解封/额度与 VIP 人工调整(事务+流水)
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- data-layer
|
||||||
|
- id: goods-order-module
|
||||||
|
content: 实现商品管理(CRUD、上下架)与订单退款管理(列表/详情/退款审核状态流转)
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- data-layer
|
||||||
|
- id: content-notify-module
|
||||||
|
content: 实现内容审核(穿搭审核封禁、衣物管理、分享封禁)与通知推送(单用户/全量广播)
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- data-layer
|
||||||
|
- id: finalize
|
||||||
|
content: 编写 README 与接口文档,执行 go mod tidy、go build、go vet 验证整体可用性
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- admin-module
|
||||||
|
- dashboard-module
|
||||||
|
- user-module
|
||||||
|
- goods-order-module
|
||||||
|
- content-notify-module
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
为「智能衣橱」小程序业务服务(zt-closet-backend)配套的管理后台后端服务(zt-closet-manage-backend)。采用 Go 语言实现,整体代码风格、目录分层、技术栈与业务服务保持一致。管理后台与业务服务共用同一 MySQL 业务库,直接读写业务数据表;管理员账号表(admin_users)新建在同一库中。管理员通过「账号密码 + JWT 双 token」方式登录鉴权。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **管理员账号**:账号密码登录(bcrypt 加密存储)、access/refresh 双 token 签发与刷新、登出、修改自己密码;超级管理员可增删改普通管理员、禁用/启用账号;初始超级管理员由配置在启动时幂等创建;被禁用账号的 token 立即失效。
|
||||||
|
- **仪表盘统计**:用户总数/今日新增、订单总数/各状态分布、累计营收与今日营收(已支付订单,金额单位分)、待处理退款数、上架商品数、待审核穿搭数等核心指标一次接口返回。
|
||||||
|
- **用户管理**:用户分页列表(按昵称/手机号/OpenID/状态搜索)、用户详情(含四类额度与 VIP 信息)、封禁/解封(临时/永久,写封禁原因与期限)、额度人工调整(事务更新 user_quotas + 写 quota_flow_logs 人工调整流水)、VIP 人工调整(事务更新 users + 写 vip_flow_logs 流水)。
|
||||||
|
- **商品管理**:商品分页列表(含下架商品)、新增、编辑、上下架、软删除;维护会员天数与四类额度权益字段,价格单位分。
|
||||||
|
- **订单与退款管理**:订单分页列表(按状态/订单号/用户筛选)、订单详情(含支付与退款信息)、退款申请列表与审核——审核通过将退款单从「待处理」置为「退款中」(实际打款由运营在微信商户平台人工完成,业务服务回调联动状态),驳回置为「失败」并记录原因。
|
||||||
|
- **内容审核**:公开穿搭列表与详情(含衣物明细)、审核通过/驳回(写 outfit_audit_logs)、穿搭封禁/解封;衣物分页列表与详情(按用户/处理状态筛选);衣柜分享列表、封禁/解封。
|
||||||
|
- **通知推送**:向单个用户或全量用户发送系统通知(写 user_notifications,msg_type=3 系统、sender_id=0,并同步 Redis 未读计数)。
|
||||||
|
|
||||||
|
所有接口沿用业务服务约定:统一前缀 `/api/v1`、统一响应 `{code, msg, data}`、分页 `page/page_size`、金额单位分、错误码分段管理。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
- 语言:Go(go.mod 现为 go 1.26.1,依赖版本全部对齐业务服务 /root/zt-closet-backend/go.mod)
|
||||||
|
- Web 框架:Gin v1.12.0
|
||||||
|
- ORM/数据库:GORM v1.31.2 + MySQL(含 soft_delete 软删除插件)
|
||||||
|
- 缓存:go-redis v9(管理员 token 会话、通知未读计数)
|
||||||
|
- 配置:spf13/viper(仅读 configs/config.yaml,不支持环境变量覆盖,与业务服务一致)
|
||||||
|
- 鉴权:golang-jwt/v5(HS256 双 token)+ golang.org/x/crypto/bcrypt(密码哈希)
|
||||||
|
- 可观测性:OpenTelemetry(trace/metric/log 三信号 OTLP gRPC 上报,otelgin + otelslog)+ log/slog + lumberjack(JSON 日志按大小轮转压缩,注入 trace_id/span_id)
|
||||||
|
- 依赖裁剪:不引入 COS / 微信支付 / Tokenhub / 上传 / 迁移(golang-migrate)相关依赖
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 总体策略
|
||||||
|
|
||||||
|
完全复刻业务服务已验证的「handler → service → dao → model」四层架构与工程规范:`pkg/app` 统一响应、`pkg/errcode` 注册式错误码、`internal/dto` 请求响应 DTO、`internal/middleware` 中间件链、`internal/service/constants.go` 枚举常量、每层方法开启 tracing span、`global/` 全局单例。管理后台连接业务库与 Redis,读写业务表时严格遵守业务服务的枚举语义与流水口径(额度/VIP 人工调整流水、退款状态机、穿搭可见性规则等)。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
subgraph 请求链
|
||||||
|
A[HTTP 请求] --> B[中间件链<br/>Recovery/RequestID/OTel/Slog/CORS]
|
||||||
|
B --> C[JWTAuth 管理员鉴权]
|
||||||
|
C --> D[Handler routers/api/v1]
|
||||||
|
end
|
||||||
|
D --> E[Service 业务逻辑层]
|
||||||
|
E --> F[mysqldao GORM 数据访问]
|
||||||
|
E --> G[redisdao 会话/计数]
|
||||||
|
F --> H[(共享 MySQL 业务库<br/>users/goods/orders...<br/>admin_users 新建)]
|
||||||
|
G --> I[(Redis<br/>admin token 会话/未读计数)]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. **建表方案**:业务库的 schema_migrations 由业务服务的 golang-migrate 管理,管理后台**不引入** golang-migrate,改用启动时 GORM AutoMigrate 创建 `admin_users` 表并幂等 seed 初始超级管理员(账号密码来自配置 Admin 段),避免版本冲突。
|
||||||
|
2. **JWT 隔离**:管理后台独立的 `pkg/lzcwyjwt`(Claims 用 `AdminID` 替代 `UserID`,函数签名风格保持一致),配置使用独立 `JWT` 段的 SecretKey/Issuer,与业务服务 token 不互通(Issuer + SecretKey 双重隔离);access token 会话写 Redis(仿业务服务 redisdao/auth.go),管理员禁用/删除立即失效。
|
||||||
|
3. **权限控制**:`admin_users.Role`(1 超级管理员 / 2 普通管理员);管理员账号管理接口用 `AdminSuperAuth` 中间件校验角色;普通管理员禁用状态在 JWTAuth 中回查数据库拒绝(仿业务服务「Redis 失败 fail-open + DB 状态回查」策略)。
|
||||||
|
4. **额度人工调整**:事务内 `SELECT ... FOR UPDATE` 锁 user_quotas 行 → 更新 Total(保证 total ≥ used)→ 写 quota_flow_logs(ChangeType=5 人工调整、ChangeTarget=1、ChangeNum/OldValue/NewValue/Reason,OrderNo 用流水 UUID 填充以满足 `(order_no, quota_type, change_type)` 联合唯一索引)。
|
||||||
|
5. **VIP 人工调整**:事务内更新 users.VIPType/VIPStart/VIPExpire → 写 vip_flow_logs(ChangeType=5 人工调整、Source=4、OrderNo 用流水 UUID)。
|
||||||
|
6. **退款审核状态机**:仅允许 `0 待处理 → 1 退款中`(通过,写审核备注)与 `0 待处理 → 3 失败`(驳回,写原因);实际微信打款由运营在商户平台手动完成,业务服务退款回调负责后续 2 成功流转与权益回收。
|
||||||
|
7. **通知推送**:写 user_notifications(msg_type=3、sender_id=0、is_read=1)成功后 best-effort 对 Redis 未读计数自增(仿业务服务通知实现);全量广播按用户分批(每批 500)遍历插入,避免单事务过大与内存暴涨。
|
||||||
|
8. **仪表盘统计**:对业务表做 COUNT/SUM 聚合查询(用户、订单各状态、已支付订单营收、今日新增、待处理退款、待审穿搭等),一次接口内并行/顺序聚合后组装返回。
|
||||||
|
9. **错误码**:复用公共段(10000 参数 / 11000 鉴权 / 12000 权限 / 19000 系统),管理域新开段位:15000-15999 管理员域、16000-16999 用户管理域、17000-17999 商品域、18000-18999 订单退款域、20000-20999 内容审核域、21000-21999 通知域(独立进程,与业务服务码值无冲突)。
|
||||||
|
|
||||||
|
### 实现注意事项
|
||||||
|
|
||||||
|
- **性能**:列表一律分页(page_size ≤ 50)+ 索引字段筛选(username/wx_open_id/phone/order_no/status);用户详情一次 IN 查询四类额度避免 N+1;穿搭详情连查 outfit_items + clothes;全量广播分批插入。
|
||||||
|
- **安全**:密码 bcrypt 哈希;初始管理员密码仅存配置(不落库明文);JWT SecretKey 启动强校验非空;所有 SQL 走 GORM 参数绑定;请求日志沿用脱敏规则(token/password 等字段打码);封禁/调整操作记录管理员 ID 与原因(CreatedBy/Reason)。
|
||||||
|
- **日志与可观测性**:沿用业务服务 setup 顺序(setting → otel → slog → mysql → redis),ServiceName 使用 `zt-closet-manage-backend`;OTel 初始化失败降级 no-op 不阻断启动;优雅关闭含 OTel flush(独立 5s context)。
|
||||||
|
- **blast radius**:只读业务表时一律走 soft_delete 过滤(deleted_at 为空);不修改业务服务已有代码;商品软删沿用 Model 基座;业务表结构不 AutoMigrate(避免误改业务表),仅 admin_users 由管理后台迁移。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/zt-closet-manage-backend/
|
||||||
|
├── main.go # [MODIFY] 入口:setupSetting→setupOTel→setupSlogLogger→setupMysqlEngine→setupRedisEngine→seed 管理员→路由→http.Server→优雅关闭
|
||||||
|
├── go.mod # [MODIFY] 按业务服务裁剪依赖清单
|
||||||
|
├── .gitignore # [NEW] 忽略 config.yaml/storage/二进制
|
||||||
|
├── README.md # [NEW] 部署、配置、接口说明(仿业务服务风格)
|
||||||
|
├── configs/
|
||||||
|
│ └── config.example.yaml # [NEW] Server/Log/Mysql/Redis/OTel/JWT/Admin 段模板
|
||||||
|
├── global/
|
||||||
|
│ ├── setting.go # [NEW] 各配置段全局指针(Server/Log/Mysql/Redis/Otel/JWT/Admin)
|
||||||
|
│ ├── mysql.go # [NEW] global.MysqlEngine
|
||||||
|
│ ├── redis.go # [NEW] global.RedisEngine
|
||||||
|
│ └── otel.go # [NEW] global.Tracer/Meter/OTelShutdown
|
||||||
|
├── pkg/
|
||||||
|
│ ├── app/response.go # [NEW] 统一响应 {code,msg,data}(复刻业务服务)
|
||||||
|
│ ├── errcode/
|
||||||
|
│ │ ├── errcode.go # [NEW] NewError 注册式错误码 + StatusCode 映射
|
||||||
|
│ │ ├── common_code.go # [NEW] 公共段 10000-19999(复刻)
|
||||||
|
│ │ └── admin_code.go # [NEW] 管理域 15000-21999 分段错误码
|
||||||
|
│ ├── lzcwyjwt/lzcwyjwt.go # [NEW] Claims{AdminID,TokenType},GenerateTokens/ParseAccessToken/ParseRefreshToken
|
||||||
|
│ ├── setting/
|
||||||
|
│ │ ├── setting.go # [NEW] viper 仅读 configs/config.yaml
|
||||||
|
│ │ └── section.go # [NEW] Server/Log/Mysql/Redis/Otel/JWT/Admin 配置结构体
|
||||||
|
│ └── tracing/
|
||||||
|
│ ├── provider.go # [NEW] OTel Provider(失败降级 no-op)
|
||||||
|
│ ├── otel.go # [NEW] otelgin 中间件适配
|
||||||
|
│ └── fanout.go # [NEW] slog fanout handler + OtelContextHandler
|
||||||
|
├── internal/
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── common.go # [NEW] PageRequest/PageResponse
|
||||||
|
│ │ ├── admin.go # [NEW] 登录/刷新/改密/管理员 CRUD DTO
|
||||||
|
│ │ ├── dashboard.go # [NEW] 统计响应 DTO
|
||||||
|
│ │ ├── user.go # [NEW] 用户列表/详情/封禁/额度/VIP 调整 DTO
|
||||||
|
│ │ ├── goods.go # [NEW] 商品 CRUD DTO
|
||||||
|
│ │ ├── order.go # [NEW] 订单/退款审核 DTO
|
||||||
|
│ │ ├── content.go # [NEW] 穿搭审核/衣物/分享 DTO
|
||||||
|
│ │ └── notification.go # [NEW] 通知推送 DTO
|
||||||
|
│ ├── middleware/
|
||||||
|
│ │ ├── requestid.go # [NEW] X-Request-ID(复刻)
|
||||||
|
│ │ ├── otel.go # [NEW] GinOTelMiddleware(复刻)
|
||||||
|
│ │ ├── logger.go # [NEW] GinSlogMiddleware 访问日志+脱敏(复刻)
|
||||||
|
│ │ ├── cors.go # [NEW] CORS(复刻)
|
||||||
|
│ │ ├── jwtauth.go # [NEW] 管理员 JWTAuth(token→Redis 会话→DB 状态回查→AdminID 注入 ctx)+ AdminSuperAuth 超管校验
|
||||||
|
│ ├── mysqldao/
|
||||||
|
│ │ ├── admin.go # [NEW] 管理员账号 DAO(含按用户名查询、分页)
|
||||||
|
│ │ ├── user.go # [NEW] 用户分页/详情/封禁/额度/VIP 更新 DAO
|
||||||
|
│ │ ├── goods.go # [NEW] 商品 CRUD DAO
|
||||||
|
│ │ ├── order.go # [NEW] 订单分页/详情 DAO
|
||||||
|
│ │ ├── refund.go # [NEW] 退款单分页/详情/状态流转 DAO
|
||||||
|
│ │ ├── outfit.go # [NEW] 穿搭分页/详情/审核/封禁 + audit log DAO
|
||||||
|
│ │ ├── clothes.go # [NEW] 衣物分页/详情 DAO
|
||||||
|
│ │ ├── closet.go # [NEW] 分享分页/封禁 DAO
|
||||||
|
│ │ ├── notification.go # [NEW] 通知批量插入 DAO
|
||||||
|
│ │ └── stats.go # [NEW] 仪表盘聚合统计 DAO
|
||||||
|
│ ├── mysqlmodel/
|
||||||
|
│ │ ├── model.go # [NEW] Model 基座 + NewMysqlEngine(复刻)+ AutoMigrate(admin_users)
|
||||||
|
│ │ ├── adminuser.go # [NEW] AdminUser 模型(表 admin_users)
|
||||||
|
│ │ └── business.go # [NEW] 业务表模型:User/Goods/Order/RefundFlowLog/Outfit/OutfitItem/OutfitAuditLog/Clothes/ClosetShare/Quota/QuotaFlowLog/VIPFlowLog/UserNotification(结构与业务服务一致)
|
||||||
|
│ ├── redisdao/
|
||||||
|
│ │ ├── auth.go # [NEW] 管理员 token 会话存取/删除
|
||||||
|
│ │ └── notification.go # [NEW] 通知未读计数自增
|
||||||
|
│ ├── redismodel/redismodel.go # [NEW] NewRedisEngine + key 常量
|
||||||
|
│ ├── routers/
|
||||||
|
│ │ ├── router.go # [NEW] 中间件链 + /ping + 公开/鉴权路由注册
|
||||||
|
│ │ └── api/v1/
|
||||||
|
│ │ ├── admin.go # [NEW] 登录/刷新/登出/改密/管理员 CRUD handler
|
||||||
|
│ │ ├── dashboard.go # [NEW] 仪表盘 handler
|
||||||
|
│ │ ├── user.go # [NEW] 用户管理 handler
|
||||||
|
│ │ ├── goods.go # [NEW] 商品管理 handler
|
||||||
|
│ │ ├── order.go # [NEW] 订单/退款 handler
|
||||||
|
│ │ ├── content.go # [NEW] 穿搭/衣物/分享 handler
|
||||||
|
│ │ └── notification.go # [NEW] 通知推送 handler
|
||||||
|
│ └── service/
|
||||||
|
│ ├── service.go # [NEW] normalizePage/isNotFound 等公共函数
|
||||||
|
│ ├── constants.go # [NEW] 管理后台枚举常量(对齐业务服务语义)
|
||||||
|
│ ├── admin.go # [NEW] 登录鉴权/密码/管理员管理逻辑(bcrypt、seed)
|
||||||
|
│ ├── dashboard.go # [NEW] 统计组装
|
||||||
|
│ ├── user.go # [NEW] 用户管理(封禁/额度/VIP 调整事务+流水)
|
||||||
|
│ ├── goods.go # [NEW] 商品业务逻辑
|
||||||
|
│ ├── order.go # [NEW] 订单/退款审核状态机
|
||||||
|
│ ├── content.go # [NEW] 穿搭审核/封禁、衣物、分享封禁
|
||||||
|
│ └── notification.go # [NEW] 单用户/全量广播
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键代码结构
|
||||||
|
|
||||||
|
```
|
||||||
|
// internal/mysqlmodel/adminuser.go —— 管理员账号表(管理后台唯一新建表)
|
||||||
|
type AdminUser struct {
|
||||||
|
Model
|
||||||
|
Username string `gorm:"column:username;uniqueIndex:uk_admin_username"` // 登录账号
|
||||||
|
PasswordHash string `gorm:"column:password_hash"` // bcrypt 哈希
|
||||||
|
Nickname string `gorm:"column:nickname"` // 显示名
|
||||||
|
Role int8 `gorm:"column:role"` // 1=超级管理员 2=普通管理员
|
||||||
|
Status int8 `gorm:"column:status"` // 1=启用 2=禁用
|
||||||
|
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
|
||||||
|
}
|
||||||
|
|
||||||
|
// pkg/lzcwyjwt/lzcwyjwt.go —— 管理员 JWT Claims(与业务服务接口风格一致,字段替换为 AdminID)
|
||||||
|
type Claims struct {
|
||||||
|
AdminID int64 `json:"admin_id"`
|
||||||
|
TokenType TokenType `json:"token_type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
func GenerateTokens(adminID int64) (accessToken, refreshToken string, err error)
|
||||||
|
func ParseAccessToken(tokenString string) (*Claims, error)
|
||||||
|
func ParseRefreshToken(tokenString string) (*Claims, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 路由规划(前缀 /api/v1)
|
||||||
|
|
||||||
|
- 公开:`GET /ping`、`POST /admin/login`、`POST /admin/refresh-token`
|
||||||
|
- 鉴权(JWT):`POST /admin/logout`、`PUT /admin/password`;`GET /dashboard/summary`;`GET /users`、`GET /users/:uuid`、`PUT /users/:uuid/ban`、`PUT /users/:uuid/unban`、`PUT /users/:uuid/quota`、`PUT /users/:uuid/vip`;`GET /goods`、`POST /goods`、`PUT /goods/:id`、`PUT /goods/:id/status`、`DELETE /goods/:id`;`GET /orders`、`GET /orders/:orderNo`;`GET /refunds`、`POST /refunds/:refundNo/approve`、`POST /refunds/:refundNo/reject`;`GET /outfits`、`GET /outfits/:uuid`、`POST /outfits/:uuid/audit`、`POST /outfits/:uuid/ban`、`POST /outfits/:uuid/unban`;`GET /clothes`、`GET /clothes/:uuid`;`GET /closet-shares`、`POST /closet-shares/:uuid/ban`、`POST /closet-shares/:uuid/unban`;`POST /notifications`、`POST /notifications/broadcast`
|
||||||
|
- 超管专属:`GET /admins`、`POST /admins`、`PUT /admins/:id`、`DELETE /admins/:id`
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
---
|
||||||
|
name: 修复管理后台衣物图片与头像URL
|
||||||
|
overview: 管理后台衣物列表/详情与穿搭明细返回的 processed_image 是 COS 相对 key、用户头像 avatar 同理,导致前端无法访问图片。参照业务服务 upload.GetCosUrl 逻辑,为管理后台补齐 COS 下载域名/鉴权密钥配置与 CDN 签名 URL 转换,使图片可正常展示。
|
||||||
|
todos:
|
||||||
|
- id: add-cos-config
|
||||||
|
content: 新增 Cos 配置段:pkg/setting/section.go、global/setting.go、main.go 与 configs/config.example.yaml
|
||||||
|
status: completed
|
||||||
|
- id: add-cos-url-helper
|
||||||
|
content: 新增 pkg/cos/cosurl.go 实现 GetCosUrl(复刻业务 MD5 CDN 签名,含空值与未配置兜底)
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- add-cos-config
|
||||||
|
- id: convert-clothes-images
|
||||||
|
content: 修改 service/content.go:衣物列表/详情与穿搭详情衣物图片转可访问 URL
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- add-cos-url-helper
|
||||||
|
- id: convert-user-avatar
|
||||||
|
content: 修改 service/user.go:用户头像做 http(s) 透传兼容并转 URL
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- add-cos-url-helper
|
||||||
|
- id: finalize-verify
|
||||||
|
content: 更新 README 配置与安全说明,执行 go build 与 go vet 验证
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- convert-clothes-images
|
||||||
|
- convert-user-avatar
|
||||||
|
---
|
||||||
|
|
||||||
|
## 用户需求
|
||||||
|
|
||||||
|
管理后台前端无法展示用户上传的衣物图片,需要修复图片地址生成逻辑。
|
||||||
|
|
||||||
|
## 根因
|
||||||
|
|
||||||
|
衣物图片上传 COS 后,数据库 `clothes.processed_image` 存的是相对 COS key(如 `{userUUID}/{userID}/{uuid}.png`),业务服务返回前会调用 `upload.GetCosUrl()` 生成带 CDN 鉴权签名的完整 URL。管理后台目前直接返回相对 key,导致前端拿到的地址无法访问。
|
||||||
|
|
||||||
|
用户头像存在同类问题:`users.avatar` 存的也是 COS key 或历史完整 URL,管理后台同样直接返回未转换的原始值。
|
||||||
|
|
||||||
|
## 修复范围
|
||||||
|
|
||||||
|
1. 管理后台新增 COS 下载配置(CDN 下载域名 + 下载鉴权密钥),与业务服务 Cos 段保持一致。
|
||||||
|
2. 新增 `GetCosUrl` 工具函数,复刻业务服务 MD5 CDN 签名逻辑。
|
||||||
|
3. 衣物列表/详情、穿搭详情中的衣物图片地址转为可访问 URL。
|
||||||
|
4. 用户列表/详情中的头像地址做兼容转换(`http(s)` 完整 URL 透传,COS key 转 URL)。
|
||||||
|
5. 补充配置说明与 README,确保配置为机密项、不落库、不硬编码。
|
||||||
|
|
||||||
|
## 技术方案
|
||||||
|
|
||||||
|
### 实现策略
|
||||||
|
|
||||||
|
完全复刻业务服务的图片 URL 生成逻辑,保持两服务共用同一 COS 时签名结果一致。仅改管理后台,不改业务服务。
|
||||||
|
|
||||||
|
### 关键改动
|
||||||
|
|
||||||
|
1. **配置层新增 Cos 段(仅下载所需字段)**
|
||||||
|
|
||||||
|
- `pkg/setting/section.go`:新增 `CosSettingS{CosDownloadDomain string; CdnDownloadSecretKey string}`。
|
||||||
|
- `global/setting.go`:新增 `CosSetting *setting.CosSettingS`。
|
||||||
|
- `main.go`:`setupSetting` 中读取 `Cos` 段。
|
||||||
|
- `configs/config.example.yaml`:新增 `Cos:` 段,注释说明须与业务服务 Cos 段一致、属机密项。
|
||||||
|
|
||||||
|
2. **新增 URL 生成工具 `pkg/cos/cosurl.go`**
|
||||||
|
复刻业务 `upload.GetCosUrl`:
|
||||||
|
|
||||||
|
- `ts := time.Now().Unix()`
|
||||||
|
- `raw := CdnDownloadSecretKey + "/" + fileName + ts`
|
||||||
|
- `sign := md5(raw)`
|
||||||
|
- 返回 `https://{CosDownloadDomain}/{fileName}?sign={sign}&t={ts}`
|
||||||
|
- 增加保护:`fileName == ""` 或 Cos 配置缺失/下载域名为空时,原样返回 fileName,避免未配置时 panic 或生成无效 URL。
|
||||||
|
|
||||||
|
3. **接入图片转换**
|
||||||
|
|
||||||
|
- `internal/service/content.go`:
|
||||||
|
- `buildClothesItem` 中 `ProcessedImage` 改为 `cos.GetCosUrl(c.ProcessedImage)`。
|
||||||
|
- `OutfitDetail` 中衣物明细 `ProcessedImage` 改为 `cos.GetCosUrl(cl.ProcessedImage)`。
|
||||||
|
- `internal/service/user.go`:
|
||||||
|
- `buildUserListItem` 中 `Avatar` 增加兼容转换:以 `http://`/`https://` 开头则透传,否则 `cos.GetCosUrl(u.Avatar)`;建议提取内部辅助函数 `toImageURL(key string) string` 供 avatar 复用。
|
||||||
|
|
||||||
|
4. **文档与验证**
|
||||||
|
|
||||||
|
- `README.md`:配置段说明新增 `Cos`;安全注意事项注明 `CdnDownloadSecretKey` 为机密,仅存 `configs/config.yaml`(已 gitignore),与业务服务保持一致。
|
||||||
|
- `go build ./...` 与 `go vet ./...` 必须通过。
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/zt-closet-manage-backend/
|
||||||
|
├── configs/config.example.yaml # [MODIFY] 新增 Cos 段
|
||||||
|
├── global/setting.go # [MODIFY] 新增 CosSetting
|
||||||
|
├── pkg/setting/section.go # [MODIFY] 新增 CosSettingS
|
||||||
|
├── pkg/cos/cosurl.go # [NEW] GetCosUrl 工具
|
||||||
|
├── internal/service/content.go # [MODIFY] 衣物/穿搭图片转 URL
|
||||||
|
├── internal/service/user.go # [MODIFY] 头像转 URL
|
||||||
|
├── main.go # [MODIFY] setupSetting 读取 Cos 段
|
||||||
|
└── README.md # [MODIFY] 配置与安全说明
|
||||||
|
```
|
||||||
|
|
||||||
|
### 注意事项
|
||||||
|
|
||||||
|
- 管理后台与业务服务共用同一 COS,`Cos.CosDownloadDomain` 与 `Cos.CdnDownloadSecretKey` 必须与业务服务 `configs/config.yaml` 中的 `Cos` 段一致,否则签名校验失败。
|
||||||
|
- 仅做 URL 转换,不改动数据库存储的相对 key;对空值、未配置做安全兜底。
|
||||||
|
- 不引入 COS SDK,仅需 `crypto/md5`、`fmt`、`strconv`、`time` 标准库,避免额外依赖。
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: 同步业务后端额度体系与虚拟支付改动
|
||||||
|
overview: 将业务服务 /root/zt-closet-backend 的最新改动同步到管理后台 /root/zt-closet-manage-backend:同步商品/订单新字段与 quota_grants 额度凭证模型,并将「额度人工调整」从改 user_quotas.total 重构为发放有有效期额度凭证(quota_grants),用户详情完整展示永久/每日额度与凭证列表、支持回收。
|
||||||
|
todos:
|
||||||
|
- id: sync-models-constants
|
||||||
|
content: 同步数据模型与常量:business.go 增改 Goods/Order/QuotaGrant/UserDailyUsage,constants.go 补 GoodsProduct/PayChannelVirtual/QuotaGrantSource 等
|
||||||
|
status: completed
|
||||||
|
- id: sync-goods-order
|
||||||
|
content: 同步商品与订单 DTO/Service:商品 valid_days 可编辑与 product 只读展示,订单 xpay 字段展示
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- sync-models-constants
|
||||||
|
- id: add-quota-config
|
||||||
|
content: 新增 Quota 配置段:pkg/setting、global、main.go 与 config.example.yaml
|
||||||
|
status: completed
|
||||||
|
- id: quota-dao-dto
|
||||||
|
content: 实现 QuotaGrantDao 与 UserDailyUsage 读取,移除旧 QuotaAdjustWithFlow,重构 dto/user.go
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- sync-models-constants
|
||||||
|
- add-quota-config
|
||||||
|
- id: rework-user-service
|
||||||
|
content: 重构 service/user.go:详情完整额度计算与凭证列表、额度调整改凭证发放、新增凭证回收
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- quota-dao-dto
|
||||||
|
- id: router-handler-finalize
|
||||||
|
content: 改造路由与 handler、补充错误码、更新 README 并执行 go build/vet 验证
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- rework-user-service
|
||||||
|
- sync-goods-order
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
业务后端(/root/zt-closet-backend)已完成额度体系重构与商品、订单字段扩展,本次任务是将管理后台(/root/zt-closet-manage-backend)同步到新语义:新增有有效期额度凭证(quota_grants)的发放与回收、用户详情完整展示永久额度与每日额度、同步商品虚拟支付字段与订单 XPay 字段,保持与业务服务共用同一 MySQL 库、同风格四层架构。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 数据模型同步:Goods 新增 valid_days(额度有效期天数)、product_id、product_status;Order 新增 xpay_product_id、xpay_platform、xpay_env;新增 QuotaGrant、UserDailyUsage 模型;user_quotas 语义更新为仅 Cos 永久额度。
|
||||||
|
- 额度人工调整改为凭证发放:管理后台将用户额度调整从「改 user_quotas.total」改为「写 quota_grants 凭证」(source=加量包,ref_id=manual_uuid,可设有效期天数);Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量。
|
||||||
|
- 凭证回收:支持按凭证 ID 软删除 quota_grants(含归属校验)。
|
||||||
|
- 用户详情完整展示:Cos 永久额度(总额/已用/剩余)+ AI/社区/衣柜 每日额度(总额/当日已用/剩余)+ 该用户有效额度凭证列表;免费额度底值由管理后台新增 Quota 配置段提供(未配回落 20/3/10/5)。
|
||||||
|
- 商品管理:valid_days 可编辑,product_id/product_status 只读展示(虚拟支付发布状态由业务侧维护)。
|
||||||
|
- 订单管理:列表与详情展示 pay_channel=2(虚拟支付)及 xpay 平台/环境/道具 ID 字段。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
沿用现有管理后台技术栈与分层,不做技术栈变更:
|
||||||
|
|
||||||
|
- Go 1.26 + Gin + GORM + MySQL(soft_delete)+ go-redis + viper + JWT + OpenTelemetry + slog。
|
||||||
|
- 与业务服务共用同一 MySQL 业务库与 Redis。
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 总体策略
|
||||||
|
|
||||||
|
严格对齐业务后端新额度语义:以「免费配置值 + 有效凭证」计算有效额度,凭证表作为可追溯、可回收的发放载体。商品/订单字段仅做模型与 DTO/Service 同步展示;额度调整与详情展示按新语义重构。
|
||||||
|
|
||||||
|
### 额度计算模型(管理后台侧)
|
||||||
|
|
||||||
|
管理后台不依赖业务 Redis 计数,直接读共享库 MySQL:
|
||||||
|
|
||||||
|
- Cos 永久额度:`Total = Quota配置.Cos + SUM(有效Cos凭证.amount)`,`Used = user_quotas.used + SUM(有效Cos凭证.used)`。
|
||||||
|
- AI/社区/衣柜 每日额度:`Total = Quota配置对应项 + SUM(有效凭证.amount)`,`Used = user_daily_usages 今日对应计数`,凭证 Used 恒 0。
|
||||||
|
- 凭证有效性:`expire_time=0` 跟随 VIP(vip_expire>now 有效),否则 `expire_time>now` 有效。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[管理员发放额度] --> B[写 quota_grants<br/>source=2 加量包<br/>ref_id=manual_uuid<br/>expire_time=now+days]
|
||||||
|
B --> C[(quota_grants)]
|
||||||
|
C --> D[用户详情计算]
|
||||||
|
D --> E[Cos 永久额度<br/>免费配置 + 凭证库存]
|
||||||
|
D --> F[AI/社区/衣柜 每日额度<br/>免费配置 + 凭证每日加量]
|
||||||
|
G[(user_quotas)] --> E
|
||||||
|
H[(user_daily_usages)] --> F
|
||||||
|
I[管理员回收] -->|软删| C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. 新增 `QuotaSettingS{Cos,AI,OutfitShare,ClosetShare *int64}` 配置段(指针类型支持显式 0,未配/负数回落 20/3/10/5,与业务 `defaultQuotaValue` 对齐),仅用于管理后台计算总额展示。
|
||||||
|
2. 凭证发放请求改为 `{quota_type, amount, expire_days}`,`source=2 加量包`、`ref_id="manual_"+UUID`、`CreatedBy=管理员ID`;不写 quota_flow_logs(与业务广告激励模式一致)。
|
||||||
|
3. 凭证回收为软删除,`DELETE /users/:uuid/grants/:id`,按 `id + user_id` 归属校验,防越权。
|
||||||
|
4. 商品 product_id/product_status 只读展示(由业务虚拟支付发布流程维护),valid_days 参与创建与更新。
|
||||||
|
5. 每日额度「当日已用」读 `user_daily_usages` 今日 StatDate,避免依赖 Redis TTL 计数。
|
||||||
|
6. 移除旧 `QuotaAdjustWithFlow`(改 user_quotas.total + 流水)逻辑,避免遗留与新语义冲突的写路径。
|
||||||
|
7. 错误码新开 `UserGrantNotFound=16003`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/zt-closet-manage-backend/
|
||||||
|
├── configs/config.example.yaml # [MODIFY] 新增 Quota 配置段(Cos/AI/OutfitShare/ClosetShare)
|
||||||
|
├── global/setting.go # [MODIFY] 新增 QuotaSetting 全局指针
|
||||||
|
├── pkg/setting/section.go # [MODIFY] 新增 QuotaSettingS 结构体
|
||||||
|
├── pkg/errcode/admin_code.go # [MODIFY] 新增 16003 额度凭证不存在
|
||||||
|
├── main.go # [MODIFY] setupSetting 读取 Quota 段
|
||||||
|
├── internal/
|
||||||
|
│ ├── mysqlmodel/business.go # [MODIFY] Goods+3字段、Order+3字段、Quota注释、新增 QuotaGrant/UserDailyUsage
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── goods.go # [MODIFY] GoodsItem/Create/Update 增加 valid_days、product_id、product_status
|
||||||
|
│ │ ├── order.go # [MODIFY] OrderItem 增加 xpay 字段
|
||||||
|
│ │ └── user.go # [MODIFY] QuotaItem 语义、UserDetailResponse 增 Grants、QuotaAdjustRequest 改造、新增 UserGrantItem
|
||||||
|
│ ├── mysqldao/
|
||||||
|
│ │ ├── user.go # [MODIFY] 移除 QuotaAdjustWithFlow
|
||||||
|
│ │ └── quota.go # [NEW] QuotaGrantDao(Create/ListByUserID/GetByID/SoftDelete)+ UserDailyUsageGetByUserIDAndDate
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── constants.go # [MODIFY] 新增 GoodsProduct*、PayChannelVirtual、QuotaGrantSource*、XPayPlatform*
|
||||||
|
│ │ ├── goods.go # [MODIFY] buildGoodsItem/Create/Update 处理新字段
|
||||||
|
│ │ ├── order.go # [MODIFY] buildOrderItem 增加 xpay 字段
|
||||||
|
│ │ └── user.go # [MODIFY] Detail 完整额度计算、QuotaAdjust 改凭证发放、新增 GrantRevoke
|
||||||
|
│ └── routers/
|
||||||
|
│ ├── router.go # [MODIFY] 新增 DELETE /users/:uuid/grants/:id
|
||||||
|
│ └── api/v1/user.go # [MODIFY] 额度调整 handler 改参数、新增凭证回收 handler
|
||||||
|
└── README.md # [MODIFY] 更新接口、配置、业务规则说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键数据结构
|
||||||
|
|
||||||
|
```
|
||||||
|
// pkg/setting/section.go
|
||||||
|
type QuotaSettingS struct {
|
||||||
|
Cos *int64 // 免费上传额度(永久)
|
||||||
|
AI *int64 // 免费 AI 推荐额度(每日)
|
||||||
|
OutfitShare *int64 // 免费社区分享额度(每日)
|
||||||
|
ClosetShare *int64 // 免费衣柜分享额度(每日)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal/mysqlmodel/business.go
|
||||||
|
type QuotaGrant struct {
|
||||||
|
Model
|
||||||
|
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"`
|
||||||
|
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"`
|
||||||
|
Amount int64 `gorm:"column:amount"`
|
||||||
|
Used int64 `gorm:"column:used"`
|
||||||
|
Source int8 `gorm:"column:source"`
|
||||||
|
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"`
|
||||||
|
RefID string `gorm:"column:ref_id;type:varchar(64);uniqueIndex:uk_grant_ref_type,priority:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal/dto/user.go
|
||||||
|
type UserQuotaAdjustRequest struct {
|
||||||
|
QuotaType int8 `json:"quota_type" binding:"required,oneof=1 2 3 4"`
|
||||||
|
Amount int64 `json:"amount" binding:"required,min=1,max=100000000"`
|
||||||
|
ExpireDays int64 `json:"expire_days" binding:"required,min=1,max=3650"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现注意事项
|
||||||
|
|
||||||
|
- 性能:用户详情一次查询 user_quotas(Cos)、quota_grants(IN 用户)、user_daily_usages(今日),避免 N+1;凭证列表数量级小,无分页压力。
|
||||||
|
- 安全:凭证回收必须校验 `user_id` 归属;发放时校验用户存在与参数合法性;所有 SQL 走 GORM 参数绑定。
|
||||||
|
- 语义对齐:凭证有效性判断严格复刻业务 `grantExpired`;商品只读字段不提供写入入口,避免与业务虚拟支付发布流程冲突。
|
||||||
|
- 兼容性:仅改管理后台内部逻辑,不改业务服务代码;旧 `PUT /users/:uuid/quota` 路由保留但请求体语义变更,前端联调需同步。
|
||||||
|
- 验证:`go build ./...` 与 `go vet ./...` 必须通过。
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
---
|
||||||
|
name: 同步业务后端额度体系与虚拟支付改动
|
||||||
|
overview: 将业务服务 /root/zt-closet-backend 的最新改动同步到管理后台 /root/zt-closet-manage-backend:同步商品/订单新字段与 quota_grants 额度凭证模型,并将「额度人工调整」从改 user_quotas.total 重构为发放有有效期额度凭证(quota_grants),用户详情完整展示永久/每日额度与凭证列表、支持回收。
|
||||||
|
todos:
|
||||||
|
- id: sync-models-constants
|
||||||
|
content: 同步数据模型与常量:business.go 增改 Goods/Order/QuotaGrant/UserDailyUsage,constants.go 补 GoodsProduct/PayChannelVirtual/QuotaGrantSource 等
|
||||||
|
status: completed
|
||||||
|
- id: sync-goods-order
|
||||||
|
content: 同步商品与订单 DTO/Service:商品 valid_days 可编辑与 product 只读展示,订单 xpay 字段展示
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- sync-models-constants
|
||||||
|
- id: add-quota-config
|
||||||
|
content: 新增 Quota 配置段:pkg/setting、global、main.go 与 config.example.yaml
|
||||||
|
status: completed
|
||||||
|
- id: quota-dao-dto
|
||||||
|
content: 实现 QuotaGrantDao 与 UserDailyUsage 读取,移除旧 QuotaAdjustWithFlow,重构 dto/user.go
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- sync-models-constants
|
||||||
|
- add-quota-config
|
||||||
|
- id: rework-user-service
|
||||||
|
content: 重构 service/user.go:详情完整额度计算与凭证列表、额度调整改凭证发放、新增凭证回收
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- quota-dao-dto
|
||||||
|
- id: router-handler-finalize
|
||||||
|
content: 改造路由与 handler、补充错误码、更新 README 并执行 go build/vet 验证
|
||||||
|
status: completed
|
||||||
|
dependencies:
|
||||||
|
- rework-user-service
|
||||||
|
- sync-goods-order
|
||||||
|
---
|
||||||
|
|
||||||
|
## 产品概述
|
||||||
|
|
||||||
|
业务后端(/root/zt-closet-backend)已完成额度体系重构与商品、订单字段扩展,本次任务是将管理后台(/root/zt-closet-manage-backend)同步到新语义:新增有有效期额度凭证(quota_grants)的发放与回收、用户详情完整展示永久额度与每日额度、同步商品虚拟支付字段与订单 XPay 字段,保持与业务服务共用同一 MySQL 库、同风格四层架构。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- 数据模型同步:Goods 新增 valid_days(额度有效期天数)、product_id、product_status;Order 新增 xpay_product_id、xpay_platform、xpay_env;新增 QuotaGrant、UserDailyUsage 模型;user_quotas 语义更新为仅 Cos 永久额度。
|
||||||
|
- 额度人工调整改为凭证发放:管理后台将用户额度调整从「改 user_quotas.total」改为「写 quota_grants 凭证」(source=加量包,ref_id=manual_uuid,可设有效期天数);Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量。
|
||||||
|
- 凭证回收:支持按凭证 ID 软删除 quota_grants(含归属校验)。
|
||||||
|
- 用户详情完整展示:Cos 永久额度(总额/已用/剩余)+ AI/社区/衣柜 每日额度(总额/当日已用/剩余)+ 该用户有效额度凭证列表;免费额度底值由管理后台新增 Quota 配置段提供(未配回落 20/3/10/5)。
|
||||||
|
- 商品管理:valid_days 可编辑,product_id/product_status 只读展示(虚拟支付发布状态由业务侧维护)。
|
||||||
|
- 订单管理:列表与详情展示 pay_channel=2(虚拟支付)及 xpay 平台/环境/道具 ID 字段。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
沿用现有管理后台技术栈与分层,不做技术栈变更:
|
||||||
|
|
||||||
|
- Go 1.26 + Gin + GORM + MySQL(soft_delete)+ go-redis + viper + JWT + OpenTelemetry + slog。
|
||||||
|
- 与业务服务共用同一 MySQL 业务库与 Redis。
|
||||||
|
|
||||||
|
## 实现方案
|
||||||
|
|
||||||
|
### 总体策略
|
||||||
|
|
||||||
|
严格对齐业务后端新额度语义:以「免费配置值 + 有效凭证」计算有效额度,凭证表作为可追溯、可回收的发放载体。商品/订单字段仅做模型与 DTO/Service 同步展示;额度调整与详情展示按新语义重构。
|
||||||
|
|
||||||
|
### 额度计算模型(管理后台侧)
|
||||||
|
|
||||||
|
管理后台不依赖业务 Redis 计数,直接读共享库 MySQL:
|
||||||
|
|
||||||
|
- Cos 永久额度:`Total = Quota配置.Cos + SUM(有效Cos凭证.amount)`,`Used = user_quotas.used + SUM(有效Cos凭证.used)`。
|
||||||
|
- AI/社区/衣柜 每日额度:`Total = Quota配置对应项 + SUM(有效凭证.amount)`,`Used = user_daily_usages 今日对应计数`,凭证 Used 恒 0。
|
||||||
|
- 凭证有效性:`expire_time=0` 跟随 VIP(vip_expire>now 有效),否则 `expire_time>now` 有效。
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A[管理员发放额度] --> B[写 quota_grants<br/>source=2 加量包<br/>ref_id=manual_uuid<br/>expire_time=now+days]
|
||||||
|
B --> C[(quota_grants)]
|
||||||
|
C --> D[用户详情计算]
|
||||||
|
D --> E[Cos 永久额度<br/>免费配置 + 凭证库存]
|
||||||
|
D --> F[AI/社区/衣柜 每日额度<br/>免费配置 + 凭证每日加量]
|
||||||
|
G[(user_quotas)] --> E
|
||||||
|
H[(user_daily_usages)] --> F
|
||||||
|
I[管理员回收] -->|软删| C
|
||||||
|
```
|
||||||
|
|
||||||
|
### 关键设计决策
|
||||||
|
|
||||||
|
1. 新增 `QuotaSettingS{Cos,AI,OutfitShare,ClosetShare *int64}` 配置段(指针类型支持显式 0,未配/负数回落 20/3/10/5,与业务 `defaultQuotaValue` 对齐),仅用于管理后台计算总额展示。
|
||||||
|
2. 凭证发放请求改为 `{quota_type, amount, expire_days}`,`source=2 加量包`、`ref_id="manual_"+UUID`、`CreatedBy=管理员ID`;不写 quota_flow_logs(与业务广告激励模式一致)。
|
||||||
|
3. 凭证回收为软删除,`DELETE /users/:uuid/grants/:id`,按 `id + user_id` 归属校验,防越权。
|
||||||
|
4. 商品 product_id/product_status 只读展示(由业务虚拟支付发布流程维护),valid_days 参与创建与更新。
|
||||||
|
5. 每日额度「当日已用」读 `user_daily_usages` 今日 StatDate,避免依赖 Redis TTL 计数。
|
||||||
|
6. 移除旧 `QuotaAdjustWithFlow`(改 user_quotas.total + 流水)逻辑,避免遗留与新语义冲突的写路径。
|
||||||
|
7. 错误码新开 `UserGrantNotFound=16003`。
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
/root/zt-closet-manage-backend/
|
||||||
|
├── configs/config.example.yaml # [MODIFY] 新增 Quota 配置段(Cos/AI/OutfitShare/ClosetShare)
|
||||||
|
├── global/setting.go # [MODIFY] 新增 QuotaSetting 全局指针
|
||||||
|
├── pkg/setting/section.go # [MODIFY] 新增 QuotaSettingS 结构体
|
||||||
|
├── pkg/errcode/admin_code.go # [MODIFY] 新增 16003 额度凭证不存在
|
||||||
|
├── main.go # [MODIFY] setupSetting 读取 Quota 段
|
||||||
|
├── internal/
|
||||||
|
│ ├── mysqlmodel/business.go # [MODIFY] Goods+3字段、Order+3字段、Quota注释、新增 QuotaGrant/UserDailyUsage
|
||||||
|
│ ├── dto/
|
||||||
|
│ │ ├── goods.go # [MODIFY] GoodsItem/Create/Update 增加 valid_days、product_id、product_status
|
||||||
|
│ │ ├── order.go # [MODIFY] OrderItem 增加 xpay 字段
|
||||||
|
│ │ └── user.go # [MODIFY] QuotaItem 语义、UserDetailResponse 增 Grants、QuotaAdjustRequest 改造、新增 UserGrantItem
|
||||||
|
│ ├── mysqldao/
|
||||||
|
│ │ ├── user.go # [MODIFY] 移除 QuotaAdjustWithFlow
|
||||||
|
│ │ └── quota.go # [NEW] QuotaGrantDao(Create/ListByUserID/GetByID/SoftDelete)+ UserDailyUsageGetByUserIDAndDate
|
||||||
|
│ ├── service/
|
||||||
|
│ │ ├── constants.go # [MODIFY] 新增 GoodsProduct*、PayChannelVirtual、QuotaGrantSource*、XPayPlatform*
|
||||||
|
│ │ ├── goods.go # [MODIFY] buildGoodsItem/Create/Update 处理新字段
|
||||||
|
│ │ ├── order.go # [MODIFY] buildOrderItem 增加 xpay 字段
|
||||||
|
│ │ └── user.go # [MODIFY] Detail 完整额度计算、QuotaAdjust 改凭证发放、新增 GrantRevoke
|
||||||
|
│ └── routers/
|
||||||
|
│ ├── router.go # [MODIFY] 新增 DELETE /users/:uuid/grants/:id
|
||||||
|
│ └── api/v1/user.go # [MODIFY] 额度调整 handler 改参数、新增凭证回收 handler
|
||||||
|
└── README.md # [MODIFY] 更新接口、配置、业务规则说明
|
||||||
|
```
|
||||||
|
|
||||||
|
## 关键数据结构
|
||||||
|
|
||||||
|
```
|
||||||
|
// pkg/setting/section.go
|
||||||
|
type QuotaSettingS struct {
|
||||||
|
Cos *int64 // 免费上传额度(永久)
|
||||||
|
AI *int64 // 免费 AI 推荐额度(每日)
|
||||||
|
OutfitShare *int64 // 免费社区分享额度(每日)
|
||||||
|
ClosetShare *int64 // 免费衣柜分享额度(每日)
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal/mysqlmodel/business.go
|
||||||
|
type QuotaGrant struct {
|
||||||
|
Model
|
||||||
|
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"`
|
||||||
|
QuotaType int8 `gorm:"column:quota_type;index:idx_grant_user_type_expire,priority:2;uniqueIndex:uk_grant_ref_type,priority:2"`
|
||||||
|
Amount int64 `gorm:"column:amount"`
|
||||||
|
Used int64 `gorm:"column:used"`
|
||||||
|
Source int8 `gorm:"column:source"`
|
||||||
|
ExpireTime int64 `gorm:"column:expire_time;index:idx_grant_user_type_expire,priority:3"`
|
||||||
|
RefID string `gorm:"column:ref_id;type:varchar(64);uniqueIndex:uk_grant_ref_type,priority:1"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// internal/dto/user.go
|
||||||
|
type UserQuotaAdjustRequest struct {
|
||||||
|
QuotaType int8 `json:"quota_type" binding:"required,oneof=1 2 3 4"`
|
||||||
|
Amount int64 `json:"amount" binding:"required,min=1,max=100000000"`
|
||||||
|
ExpireDays int64 `json:"expire_days" binding:"required,min=1,max=3650"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 实现注意事项
|
||||||
|
|
||||||
|
- 性能:用户详情一次查询 user_quotas(Cos)、quota_grants(IN 用户)、user_daily_usages(今日),避免 N+1;凭证列表数量级小,无分页压力。
|
||||||
|
- 安全:凭证回收必须校验 `user_id` 归属;发放时校验用户存在与参数合法性;所有 SQL 走 GORM 参数绑定。
|
||||||
|
- 语义对齐:凭证有效性判断严格复刻业务 `grantExpired`;商品只读字段不提供写入入口,避免与业务虚拟支付发布流程冲突。
|
||||||
|
- 兼容性:仅改管理后台内部逻辑,不改业务服务代码;旧 `PUT /users/:uuid/quota` 路由保留但请求体语义变更,前端联调需同步。
|
||||||
|
- 验证:`go build ./...` 与 `go vet ./...` 必须通过。
|
||||||
+14
@@ -0,0 +1,14 @@
|
|||||||
|
# 实际配置(含密钥,严禁提交)
|
||||||
|
configs/config.yaml
|
||||||
|
|
||||||
|
# 本地存储(日志、上传临时文件)
|
||||||
|
storage/
|
||||||
|
|
||||||
|
# 编译产物
|
||||||
|
zt-closet-manage-backend
|
||||||
|
*.exe
|
||||||
|
*.test
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
@@ -0,0 +1,239 @@
|
|||||||
|
# zt-closet-manage-backend
|
||||||
|
|
||||||
|
「智能衣橱」小程序(zt-closet-backend)的**管理后台后端服务**。采用与业务服务一致的 Go + Gin 技术栈与代码风格(四层架构、统一响应、注册式错误码、OpenTelemetry 可观测性),与业务服务**共用同一 MySQL 业务库**,直接读写业务数据表;管理员账号表(`admin_users`)由本服务自行维护。
|
||||||
|
|
||||||
|
## 核心功能
|
||||||
|
|
||||||
|
- **管理员账号**:账号密码登录(bcrypt 存储)、JWT 双 token(access/refresh)签发与刷新、登出、修改密码;超级管理员可增删改普通管理员、禁用/启用账号;初始超级管理员由配置在启动时幂等创建;禁用/删除账号的会话立即失效。
|
||||||
|
- **仪表盘统计**:用户总数/今日新增/各状态分布、订单总数/各状态分布、累计与今日营收、累计退款、待处理退款数、待审核穿搭数、上架商品数,一次接口聚合返回。
|
||||||
|
- **用户管理**:分页搜索(昵称/手机号/OpenID/状态)、详情(含永久额度 + 每日额度 + 额度凭证列表)、封禁/解封(临时按天/永久,写原因与期限)、额度发放(有有效期额度凭证)、额度凭证回收、VIP 人工调整(事务 + 人工调整流水)。
|
||||||
|
- **商品管理**:分页列表(含下架)、新增、编辑(含 `valid_days` 额度有效期天数)、上下架、软删除;虚拟支付道具 ID 与发布状态只读展示。
|
||||||
|
- **订单与退款管理**:订单列表/详情(含虚拟支付 pay_channel=2 与 XPay 平台/环境/道具字段);退款申请审核——通过置「退款中」(实际打款由运营在微信商户平台手动完成)、驳回置「失败」并记录原因。
|
||||||
|
- **内容审核**:公开穿搭列表/详情(含衣物明细)、审核通过/驳回(写 `outfit_audit_logs`)、封禁/解封;衣物列表/详情;衣柜分享列表、封禁/解封。
|
||||||
|
- **通知推送**:单用户系统通知、全量广播(分批写入 `user_notifications` + 同步 Redis 未读计数,小程序端实时可见)。
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 分类 | 技术 |
|
||||||
|
| --- | --- |
|
||||||
|
| 语言 | Go 1.26 |
|
||||||
|
| Web 框架 | Gin v1.12 |
|
||||||
|
| ORM / 数据库 | GORM + MySQL(含 soft_delete 软删除) |
|
||||||
|
| 缓存 | go-redis v9(管理员 token 会话、通知未读计数) |
|
||||||
|
| 配置 | spf13/viper(仅读 YAML 文件) |
|
||||||
|
| 鉴权 | golang-jwt/v5(HS256 双 token)+ bcrypt |
|
||||||
|
| 可观测性 | OpenTelemetry(otelgin、otelslog、OTLP gRPC 三信号) |
|
||||||
|
| 日志 | log/slog + lumberjack(JSON、按大小轮转压缩) |
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
zt-closet-manage-backend/
|
||||||
|
├── main.go # 入口:初始化配置/OTel/日志/MySQL/Redis,建表+seed 超管,启动 HTTP 服务
|
||||||
|
├── configs/
|
||||||
|
│ └── config.example.yaml # 配置模板(含注释说明)
|
||||||
|
├── global/ # 全局单例:配置指针、DB/Redis 引擎、OTel Tracer/Meter
|
||||||
|
├── internal/
|
||||||
|
│ ├── dto/ # 请求/响应 DTO 与分页结构
|
||||||
|
│ ├── middleware/ # RequestID / OTel / 请求日志(脱敏) / CORS / 管理员 JWT / 超管权限
|
||||||
|
│ ├── mysqldao/ # MySQL 数据访问层(tracing span 命名 mysqldao.Xxx)
|
||||||
|
│ ├── mysqlmodel/ # GORM 模型:admin_users(自建)+ 业务表模型镜像
|
||||||
|
│ ├── redisdao/ # Redis 数据访问层(管理员会话、通知未读计数)
|
||||||
|
│ ├── redismodel/ # Redis 引擎初始化与 key 常量
|
||||||
|
│ ├── routers/ # 路由注册(router.go)与各 handler(api/v1)
|
||||||
|
│ └── service/ # 业务逻辑层(常量 + 各业务域 Service)
|
||||||
|
└── pkg/
|
||||||
|
├── app/ # HTTP 统一响应封装 {code, msg, data}
|
||||||
|
├── errcode/ # 统一业务错误码(公共段 + 管理域分段)
|
||||||
|
├── lzcwyjwt/ # 管理员 JWT 生成/解析(Claims.AdminID)
|
||||||
|
├── setting/ # viper 配置读取与配置结构体
|
||||||
|
└── tracing/ # OpenTelemetry 初始化与日志桥接
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 1. 环境依赖
|
||||||
|
|
||||||
|
- Go 1.26+
|
||||||
|
- MySQL(**与业务服务共用同一个库**,业务表由业务服务的迁移脚本维护)
|
||||||
|
- Redis(与业务服务共用同一实例与 DB,保证通知未读计数互通)
|
||||||
|
|
||||||
|
### 2. 配置准备
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp configs/config.example.yaml configs/config.yaml
|
||||||
|
```
|
||||||
|
|
||||||
|
编辑 `configs/config.yaml`,关键项:
|
||||||
|
|
||||||
|
- `Mysql`:填写**与业务服务相同的数据库**连接信息(`DBName` 一致)。
|
||||||
|
- `Redis`:填写与业务服务相同的 Redis 连接信息(`DB` 一致,保证未读计数 key 互通)。
|
||||||
|
- `JWT.SecretKey`:**必填**,须为强随机字符串,且**与业务服务使用不同密钥**(防止跨服务 token 混用)。
|
||||||
|
- `Admin.InitialUsername` / `Admin.InitialPassword`:初始超级管理员账号,服务启动时幂等创建(已存在则跳过)。
|
||||||
|
|
||||||
|
### 3. 启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 必须在项目根目录执行(配置路径固定为相对路径 configs/config.yaml)
|
||||||
|
go run .
|
||||||
|
```
|
||||||
|
|
||||||
|
启动时自动完成两件事(均在 `main` 中,依赖 MySQL 就绪):
|
||||||
|
|
||||||
|
1. **AutoMigrate 仅 `admin_users` 表**(业务表结构由业务服务迁移脚本维护,本服务不做任何业务表 DDL);
|
||||||
|
2. **幂等 seed 初始超级管理员**(`Admin` 配置段)。
|
||||||
|
|
||||||
|
验证健康检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl http://localhost:8080/ping
|
||||||
|
# {"message":"pong"}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置说明
|
||||||
|
|
||||||
|
| 配置段 | 说明 | 关键字段 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `Server` | HTTP 服务 | `Domain`(CORS 允许域名,`*` 或不填允许所有来源)、`RunMode`、`HttpPort`、`ReadTimeout`/`WriteTimeout` |
|
||||||
|
| `Log` | 日志 | `LogSavePath`、`LogFileName`、`MaxSize`/`MaxBackups`/`MaxAge`、`LogLevel` |
|
||||||
|
| `Mysql` | MySQL 连接池 | 用户名/密码/Host/Port/DBName 及连接池参数 |
|
||||||
|
| `Redis` | Redis 连接 | Host/Port/Password/DB 及连接池、重试、超时参数 |
|
||||||
|
| `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) |
|
||||||
|
| `Cos` | CDN 下载 | `CosDownloadDomain`、`CdnDownloadSecretKey`(用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL,须与业务服务 Cos 段一致) |
|
||||||
|
|
||||||
|
## API 概览
|
||||||
|
|
||||||
|
- 统一前缀:`/api/v1`
|
||||||
|
- 统一响应:成功 `{"code":0,"msg":"成功","data":{...}}`;失败 `{"code":<错误码>,"msg":<文案>,"details":[...]}`。判断业务结果一律以 `code` 为准。
|
||||||
|
- HTTP 状态码:鉴权类错误 401、权限 403、系统错误 500,其余 200。
|
||||||
|
- 鉴权方式:`Authorization: Bearer <access_token>`(登录接口获取);access 过期(业务码 `11002`)后用 refresh token 换取新 token。
|
||||||
|
- 分页约定:`page`(≥1)、`page_size`(1~50);响应 `total`/`page`/`page_size`/`list`。
|
||||||
|
- 金额单位:所有金额字段(`price`、`discount_price`、`pay_amount`、`refund_amount` 等)均为**分**(int64)。
|
||||||
|
|
||||||
|
### 公开路由(无需登录)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/ping` | 健康检查 |
|
||||||
|
| POST | `/admin/login` | 管理员登录 |
|
||||||
|
| POST | `/admin/refresh-token` | 刷新 token(需旧 access + refresh) |
|
||||||
|
|
||||||
|
### 鉴权路由(需登录)
|
||||||
|
|
||||||
|
**管理员(自己)**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/admin/logout` | 登出 |
|
||||||
|
| PUT | `/admin/password` | 修改自己密码 |
|
||||||
|
|
||||||
|
**仪表盘**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/dashboard/summary` | 核心指标聚合 |
|
||||||
|
|
||||||
|
**用户管理**(路径参数 `:uuid` 为用户 UUID)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/users` | 用户列表(query:page/page_size/keyword/status) |
|
||||||
|
| 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/vip` | VIP 人工调整(json:vip_type、vip_days、reason) |
|
||||||
|
| DELETE | `/users/:uuid/grants/:id` | 回收额度凭证(软删) |
|
||||||
|
|
||||||
|
**商品管理**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/goods` | 商品列表(query:page/page_size/goods_type/status/keyword) |
|
||||||
|
| POST | `/goods` | 创建商品 |
|
||||||
|
| PUT | `/goods/:id` | 更新商品(传什么改什么) |
|
||||||
|
| PUT | `/goods/:id/status` | 上下架(json:status 1上架/2下架) |
|
||||||
|
| DELETE | `/goods/:id` | 软删除商品 |
|
||||||
|
|
||||||
|
**订单与退款**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/orders` | 订单列表(query:page/page_size/order_no/user_id/status) |
|
||||||
|
| GET | `/orders/:orderNo` | 订单详情 |
|
||||||
|
| GET | `/refunds` | 退款单列表(query:page/page_size/status/order_no) |
|
||||||
|
| POST | `/refunds/:refundNo/approve` | 审核通过(待处理→退款中) |
|
||||||
|
| POST | `/refunds/:refundNo/reject` | 审核驳回(待处理→失败,写原因) |
|
||||||
|
|
||||||
|
**内容审核**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 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/unban` | 解封穿搭 |
|
||||||
|
| GET | `/clothes` | 衣物列表(query:page/page_size/user_id/image_process_status/category_main/keyword) |
|
||||||
|
| GET | `/clothes/:uuid` | 衣物详情 |
|
||||||
|
| GET | `/closet-shares` | 衣柜分享列表(query:page/page_size/user_id/status) |
|
||||||
|
| POST | `/closet-shares/:uuid/ban` | 封禁分享 |
|
||||||
|
| POST | `/closet-shares/:uuid/unban` | 解封分享 |
|
||||||
|
|
||||||
|
**通知推送**
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| POST | `/notifications` | 单用户通知(json:user_id、title、content) |
|
||||||
|
| POST | `/notifications/broadcast` | 全量广播(json:title、content;响应 sent_count) |
|
||||||
|
|
||||||
|
### 超级管理员专属路由
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| GET | `/admins` | 管理员列表 |
|
||||||
|
| POST | `/admins` | 创建管理员 |
|
||||||
|
| PUT | `/admins/:id` | 更新管理员(昵称/密码/角色/状态) |
|
||||||
|
| DELETE | `/admins/:id` | 删除管理员(软删) |
|
||||||
|
|
||||||
|
## 关键业务规则
|
||||||
|
|
||||||
|
- **退款审核状态机**:仅允许 `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`。
|
||||||
|
- **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`。
|
||||||
|
- **通知未读计数**:管理后台自增业务服务的同一 Redis key(`ztcloset:notification:unread:<user_id>`),要求两者共用同一 Redis 实例与 DB。
|
||||||
|
- **管理员会话**:access/refresh 双 token 会话写 Redis;禁用/删除管理员会失效其全部会话;JWTAuth 每次请求回查数据库状态,禁用立即生效(Redis 故障时 fail-open,但 DB 回查兜底拦截禁用账号)。
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
公共段与业务服务一致(0 成功、10000 参数、11000 鉴权、12000 权限、19000 系统);管理域独立分段:
|
||||||
|
|
||||||
|
| 段位 | 业务域 |
|
||||||
|
| --- | --- |
|
||||||
|
| 15000–15999 | 管理员(登录/账号) |
|
||||||
|
| 16000–16999 | 用户管理 |
|
||||||
|
| 17000–17999 | 商品 |
|
||||||
|
| 18000–18999 | 订单/退款 |
|
||||||
|
| 20000–20999 | 内容审核(穿搭/衣物/分享) |
|
||||||
|
| 21000–21999 | 通知 |
|
||||||
|
|
||||||
|
## 安全注意事项
|
||||||
|
|
||||||
|
- `JWT.SecretKey` 必须为强随机值且**与业务服务不同**,留空会导致启动失败;请勿使用默认占位值。
|
||||||
|
- `Cos.CdnDownloadSecretKey` 为 CDN 下载鉴权密钥(机密),仅存 `configs/config.yaml`(已被 `.gitignore` 忽略),且须与业务服务 `Cos` 段一致。
|
||||||
|
- 管理员密码一律 bcrypt 哈希入库;初始密码仅存在于 `configs/config.yaml`(已被 `.gitignore` 忽略),首次登录后请立即修改。
|
||||||
|
- 所有 SQL 走 GORM 参数绑定;请求日志对 `token`/`password` 等敏感参数自动脱敏。
|
||||||
|
- 登录接口对「账号不存在」与「密码错误」返回同一错误码,避免账号枚举攻击。
|
||||||
|
- 生产环境请将 `Server.RunMode` 设为 `release`,并通过反向代理对外提供 HTTPS。
|
||||||
|
|
||||||
|
## 部署提示
|
||||||
|
|
||||||
|
- **工作目录**:启动时必须位于项目根目录(配置路径固定为 `configs/config.yaml`)。
|
||||||
|
- **与业务服务的关系**:共用 MySQL 业务库与 Redis;业务表结构变更由业务服务迁移脚本负责,本服务只维护 `admin_users` 表,不做任何业务表 DDL。
|
||||||
|
- **多实例**:支持水平扩展;写操作通过事务与条件更新保证幂等(退款审核条件流转、额度调整行锁)。
|
||||||
|
- **可观测性后端**:按需配置 `OTel.ExporterEndpoint` 指向 OTLP Collector;无需可观测性时设置 `Enabled: false` 即可零开销关闭。
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
Server:
|
||||||
|
Domain: admin.example.com #服务域名,用于 CORS 允许来源(http/https 均放行);置 * 或不填则允许所有来源
|
||||||
|
RunMode: release #gin运行模式,debug 和 release
|
||||||
|
HttpPort: 8080
|
||||||
|
ReadTimeout: 60
|
||||||
|
WriteTimeout: 60
|
||||||
|
Log:
|
||||||
|
LogSavePath: storage/logs
|
||||||
|
LogFileName: app
|
||||||
|
LogFileExt: .log
|
||||||
|
MaxSize: 100
|
||||||
|
MaxBackups: 10
|
||||||
|
MaxAge: 30
|
||||||
|
LogLevel: Info #日志级别,Debug, Info, Warn, Error
|
||||||
|
Mysql:
|
||||||
|
UserName: your_mysql_user
|
||||||
|
Password: your_mysql_password
|
||||||
|
Host: 127.0.0.1
|
||||||
|
Port: 3306
|
||||||
|
DBName: your_db_name # 与业务服务(zt-closet-backend)共用同一个库
|
||||||
|
MaxIdleConns: 30
|
||||||
|
MaxOpenConns: 100
|
||||||
|
ConnMaxLifeTime: 3600
|
||||||
|
ConnMaxIdleTime: 600
|
||||||
|
Redis:
|
||||||
|
Host: 127.0.0.1
|
||||||
|
Port: 6379
|
||||||
|
Password: ""
|
||||||
|
DB: 0
|
||||||
|
PoolSize: 20
|
||||||
|
MinIdleConns: 5
|
||||||
|
MaxIdleConns: 20
|
||||||
|
MaxRetryBackoff: 512
|
||||||
|
MinRetryBackoff: 8
|
||||||
|
MaxRetries: 3
|
||||||
|
DialTimeout: 5
|
||||||
|
ReadTimeout: 3
|
||||||
|
WriteTimeout: 3
|
||||||
|
PoolTimeout: 4
|
||||||
|
OTel:
|
||||||
|
Enabled: true
|
||||||
|
ServiceName: zt-closet-manage-backend
|
||||||
|
ServiceVersion: 1.0.0
|
||||||
|
Environment: dev
|
||||||
|
ExporterEndpoint: 127.0.0.1:4317 # OTLP gRPC 端点
|
||||||
|
Insecure: true # 本地/内网不开 TLS
|
||||||
|
SamplerRatio: 1.0 # 采样率 0~1
|
||||||
|
JWT:
|
||||||
|
SecretKey: replace_with_a_strong_random_secret # 必填:留空会导致启动失败;务必与业务服务使用不同密钥
|
||||||
|
Issuer: zt-closet-manage # 签发方,与业务服务 Issuer 区分,防止跨服务 token 混用
|
||||||
|
AccessTokenExpire: 7200 # access token 有效期(秒)
|
||||||
|
RefreshTokenExpire: 86400 # refresh token 有效期(秒)
|
||||||
|
Admin:
|
||||||
|
# 初始超级管理员账号:服务启动时幂等创建(已存在则跳过),仅用于首次登录,登录后请及时修改密码。
|
||||||
|
InitialUsername: admin # 必填:留空则跳过初始账号创建
|
||||||
|
InitialPassword: Admin@123456 # 初始密码(明文仅存在于配置文件中,入库为 bcrypt 哈希)
|
||||||
|
Quota:
|
||||||
|
# 免费额度底值(仅用于管理后台计算并展示「总额度」),必须与业务服务 UserDefaultQuota 免费值保持一致。
|
||||||
|
# 删除某项或整段则回落内置默认(Cos 20 / AI 3 / OutfitShare 10 / ClosetShare 5);负数视为非法,同样回落默认。
|
||||||
|
Cos: 20 # 免费上传额度(永久)
|
||||||
|
AI: 3 # 免费 AI 推荐额度(每日)
|
||||||
|
OutfitShare: 10 # 免费社区分享额度(每日)
|
||||||
|
ClosetShare: 5 # 免费衣柜分享额度(每日)
|
||||||
|
Cos:
|
||||||
|
# CDN 下载配置:用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL。
|
||||||
|
# 必须与业务服务(zt-closet-backend)configs/config.yaml 的 Cos 段保持一致,否则图片签名校验失败。
|
||||||
|
CosDownloadDomain: your_cos_download_domain # CDN 下载域名
|
||||||
|
CdnDownloadSecretKey: your_cdn_download_secret_key # CDN 下载鉴权密钥(机密)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package global
|
||||||
|
|
||||||
|
import "gorm.io/gorm"
|
||||||
|
|
||||||
|
var MysqlEngine *gorm.DB
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package global
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/metric"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
// Tracer 全局 Tracer,业务层通过 Tracer.Start 创建 span。
|
||||||
|
Tracer trace.Tracer
|
||||||
|
// Meter 全局 Meter,供自定义指标使用。
|
||||||
|
Meter metric.Meter
|
||||||
|
// OTelShutdown 聚合的 OTel 优雅关闭函数,进程退出时调用以 flush 缓冲数据。
|
||||||
|
OTelShutdown func(context.Context) error
|
||||||
|
)
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package global
|
||||||
|
|
||||||
|
import "github.com/redis/go-redis/v9"
|
||||||
|
|
||||||
|
var RedisEngine *redis.Client
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package global
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ServerSetting *setting.ServerSettingS
|
||||||
|
LogSetting *setting.LogSettingS
|
||||||
|
MysqlSetting *setting.MysqlSettingS
|
||||||
|
RedisSetting *setting.RedisSettingS
|
||||||
|
OtelSetting *setting.OtelSettingS
|
||||||
|
JWTSetting *setting.JWTSettingS
|
||||||
|
AdminSetting *setting.AdminSettingS
|
||||||
|
QuotaSetting *setting.QuotaSettingS
|
||||||
|
CosSetting *setting.CosSettingS
|
||||||
|
)
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
module git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend
|
||||||
|
|
||||||
|
go 1.26.1
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/gin-gonic/gin v1.12.0
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0
|
||||||
|
github.com/spf13/viper v1.21.0
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0
|
||||||
|
go.opentelemetry.io/otel v1.46.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
|
||||||
|
go.opentelemetry.io/otel/log v0.22.0
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0
|
||||||
|
go.opentelemetry.io/otel/sdk v1.46.0
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.22.0
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.46.0
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0
|
||||||
|
golang.org/x/crypto v0.55.0
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
|
gorm.io/driver/mysql v1.6.0
|
||||||
|
gorm.io/gorm v1.31.2
|
||||||
|
gorm.io/plugin/soft_delete v1.2.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.2.0 // indirect
|
||||||
|
github.com/bytedance/gopkg v0.1.4 // indirect
|
||||||
|
github.com/bytedance/sonic v1.15.2 // indirect
|
||||||
|
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
|
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||||
|
github.com/gin-contrib/sse v1.1.1 // indirect
|
||||||
|
github.com/go-logr/logr v1.4.4 // indirect
|
||||||
|
github.com/go-logr/stdr v1.2.2 // indirect
|
||||||
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3 // indirect
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 // indirect
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||||
|
github.com/goccy/go-json v0.10.6 // indirect
|
||||||
|
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||||
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 // indirect
|
||||||
|
github.com/leodido/go-urn v1.5.0 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3 // indirect
|
||||||
|
github.com/quic-go/qpack v0.6.0 // indirect
|
||||||
|
github.com/quic-go/quic-go v0.61.0 // indirect
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 // indirect
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 // indirect
|
||||||
|
github.com/spf13/afero v1.15.0 // indirect
|
||||||
|
github.com/spf13/cast v1.10.0 // indirect
|
||||||
|
github.com/spf13/pflag v1.0.10 // indirect
|
||||||
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
|
github.com/ugorji/go/codec v1.3.2 // indirect
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.8.1 // indirect
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||||
|
go.uber.org/atomic v1.11.0 // indirect
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||||
|
golang.org/x/arch v0.30.0 // indirect
|
||||||
|
golang.org/x/net v0.58.0 // indirect
|
||||||
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
|
golang.org/x/text v0.41.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||||
|
google.golang.org/grpc v1.83.1 // indirect
|
||||||
|
google.golang.org/protobuf v1.36.12 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo=
|
||||||
|
filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
|
github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM=
|
||||||
|
github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4=
|
||||||
|
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
|
||||||
|
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.2 h1:0QtP1gevc1OZ6/H8Lb9BRZiCXd1Ftjd3OKuj1T1lBIo=
|
||||||
|
github.com/bytedance/sonic/loader v0.5.2/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||||
|
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||||
|
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||||
|
github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI=
|
||||||
|
github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.15 h1:05iP/CYtZ/w455R/KZM6rZ5ieAdh99UPtd+d3YzLmaI=
|
||||||
|
github.com/gabriel-vasile/mimetype v1.4.15/go.mod h1:azpTcoLcDZRNgFou5j+APrqQx9HqVPWa6ijYQIIVswQ=
|
||||||
|
github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko=
|
||||||
|
github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s=
|
||||||
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||||
|
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||||
|
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3 h1:4MU6YkEwx7GbcPJOZxrtbu+QfF3pJLJuaYTeAH0DYy8=
|
||||||
|
github.com/go-playground/validator/v10 v10.30.3/go.mod h1:4Axh7oCNGcoGkqLoE4YWt6n20mcEIsPRlB7vPk3lpyc=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0 h1:Q+1LV8DkHJvSYAdR83XzuhDaTykuDx0l6fkXxoWCWfw=
|
||||||
|
github.com/go-sql-driver/mysql v1.10.0/go.mod h1:M+cqaI7+xxXGG9swrdeUIoPG3Y3KCkF0pZej+SK+nWk=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
|
||||||
|
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
|
github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU=
|
||||||
|
github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||||
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||||
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||||
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
|
github.com/jinzhu/now v1.1.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/jinzhu/now v1.1.4/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
|
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||||
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV913Dw=
|
||||||
|
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
|
||||||
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
|
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
|
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||||
|
github.com/leodido/go-urn v1.5.0 h1:pLqT2kq1zpHW/1D18QMjMpdtX7cekxqtJJjg5ANyWw0=
|
||||||
|
github.com/leodido/go-urn v1.5.0/go.mod h1:9BORnCDhdPBJNDEX+w1bJisa8yOKYi116VeO96s4ifE=
|
||||||
|
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||||
|
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.3/go.mod h1:WVKg1VTActs4Qso6iwGbiFih2UIHo0ENGwNd0Lj+XmI=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3 h1:GTRvJQutkOSftxIFD5xw9aepkYNuPWmVJpffdDPYVpY=
|
||||||
|
github.com/pelletier/go-toml/v2 v2.4.3/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
|
||||||
|
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
|
||||||
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
|
github.com/quic-go/quic-go v0.61.0 h1:ui88A53s8MSVYLC56en0KQ17HARk+9986Dn0SBfKNvA=
|
||||||
|
github.com/quic-go/quic-go v0.61.0/go.mod h1:9So2anK4Tp22URSQq00k+Vo2PNkle96ycDPDHL4s9vs=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||||
|
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||||
|
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc=
|
||||||
|
github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8 h1:+jumHNA0Wrelhe64i8F6HNlS8pkoyMv5sreGx2Ry5Rw=
|
||||||
|
github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U=
|
||||||
|
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
|
||||||
|
github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=
|
||||||
|
github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY=
|
||||||
|
github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo=
|
||||||
|
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
|
||||||
|
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
|
github.com/spf13/viper v1.21.0 h1:x5S+0EU27Lbphp4UKm1C+1oQO+rKx36vfCoaVebLFSU=
|
||||||
|
github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||||
|
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||||
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
|
github.com/ugorji/go/codec v1.3.2 h1:zkEASHHyEClGeURfgNT9PJZVfAbs9oEX9QXggwWNJbc=
|
||||||
|
github.com/ugorji/go/codec v1.3.2/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
|
||||||
|
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.8.1 h1:kJNOCrvRN6rVqMO3AonIoD7Z3yjBBHKIc1SSlZcC/xM=
|
||||||
|
go.mongodb.org/mongo-driver/v2 v2.8.1/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||||
|
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1 h1:5sHc4ToTFjfSZCtGAAM6jPunICAmJX73htv372T4ipc=
|
||||||
|
go.opentelemetry.io/contrib/bridges/otelslog v0.20.1/go.mod h1:oa6kgvyz/3GYW04dohd0++xJIH4xdQY8PAbpeCMaM8M=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0 h1:TMTU0sQyqsF1QU+/Q4LAZlLOx1L3FJDbk5N2RVB1nx4=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.71.0/go.mod h1:QzTELfxkj/tFEZSD22OPPwLet5nIPmcdmZPeISk4C8M=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0 h1:v4KkRLVvE1cWqTJDfZZkTCG+Z4aolsa6RVos0FX7vqE=
|
||||||
|
go.opentelemetry.io/contrib/instrumentation/runtime v0.71.0/go.mod h1:g/xbuPC0XbgwMdKuyF5sKOUUEsorSkN6APydyFP/H9E=
|
||||||
|
go.opentelemetry.io/contrib/propagators/b3 v1.46.0 h1:OFVqWObn7xLIbOjE/koO0LS9fZJNgAyBD0msA+UQAoc=
|
||||||
|
go.opentelemetry.io/contrib/propagators/b3 v1.46.0/go.mod h1:t/d64xy7xuuEDJN/4ThqohLgRhIuQxL9y7P1v02bYuM=
|
||||||
|
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||||
|
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0 h1:Bu39F5tzJct+f2IZbB8989fwyTps3c8e7EsUQsz+vs8=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.22.0/go.mod h1:dJUwod88EsFgYCqrDHaSPzhiY9pBUpt0d85/qSfua7k=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0 h1:qkDYCAFiZXLcs1L4aY+tP2wguQ4kURANqHOQMA2et2s=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.46.0/go.mod h1:tkipS4DRzmpAmvg+Gw4++O1IdDq6TVDnvnYU6cmbQVs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||||
|
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0 h1:KdRxPiAoMptR3vfWzvjjvutTsSiwbC2uG0496rzZNfo=
|
||||||
|
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.46.0/go.mod h1:K/qSA+3G7Eovxi4K09wzrAgkWRnosS0DAOZeEpve7sM=
|
||||||
|
go.opentelemetry.io/otel/log v0.22.0 h1:5DBNnfvaJ6CVdkJ+Jle8Tzs50aSSv49TXGj9XRsEYw0=
|
||||||
|
go.opentelemetry.io/otel/log v0.22.0/go.mod h1:gzOt/R67vF2GniAqWu8Qv0SXy89f71muHcrkz76PCdc=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||||
|
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||||
|
go.opentelemetry.io/otel/metric/x v0.68.0 h1:TA/cBT23D3MnxYPwHL7YFOdYGdx0A0v+s7Mzotpd1dU=
|
||||||
|
go.opentelemetry.io/otel/metric/x v0.68.0/go.mod h1:agudOmvWhwUTjgibWDzxD2PoWYnpw5Ht5jISYOD2Hd4=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||||
|
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.22.0 h1:PRL+s6P63XT4E/bheEflopPUpVxuvANqZwtt89yhoGk=
|
||||||
|
go.opentelemetry.io/otel/sdk/log v0.22.0/go.mod h1:JNp0sBELrjCTcu5W3GzABVypeU6vDJjBS+X0JISuz+g=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0 h1:infPnfNrhCNgOUZRs3gWUg8vhoBUHihq02gwK05gzlg=
|
||||||
|
go.opentelemetry.io/otel/sdk/log/logtest v0.22.0/go.mod h1:gkQZA3z15Bv3KU9vigBTi8dFechSozRP7v94X4VZv+s=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||||
|
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||||
|
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||||
|
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||||
|
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||||
|
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||||
|
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||||
|
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||||
|
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||||
|
golang.org/x/arch v0.30.0 h1:sB9h+1gRGa2+LauFSV0tm8bK1J2yo1bx6/Uyi/P6DTU=
|
||||||
|
golang.org/x/arch v0.30.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8=
|
||||||
|
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
|
||||||
|
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
|
||||||
|
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||||
|
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||||
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
|
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||||
|
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||||
|
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||||
|
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
|
||||||
|
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||||
|
google.golang.org/grpc v1.83.1 h1:HIO0+BEtBP6soyqvqC8sNUjZ7bTs+0hFQuFF+RAy++Y=
|
||||||
|
google.golang.org/grpc v1.83.1/go.mod h1:kDyl6SKsiHKt0uylY5gtn5cEjkrIOhQOGDgIc4JGwzQ=
|
||||||
|
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||||
|
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
|
||||||
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
||||||
|
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
||||||
|
gorm.io/driver/sqlite v1.1.3/go.mod h1:AKDgRWk8lcSQSw+9kxCJnX/yySj8G3rdwYlU57cB45c=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
|
gorm.io/gorm v1.20.1/go.mod h1:0HFTzE/SqkGTzK6TlDPPQbAYCluiVvhzoA1+aVyzenw=
|
||||||
|
gorm.io/gorm v1.23.0/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk=
|
||||||
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
gorm.io/plugin/soft_delete v1.2.1 h1:qx9D/c4Xu6w5KT8LviX8DgLcB9hkKl6JC9f44Tj7cGU=
|
||||||
|
gorm.io/plugin/soft_delete v1.2.1/go.mod h1:Zv7vQctOJTGOsJ/bWgrN1n3od0GBAZgnLjEx+cApLGk=
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// AdminLoginRequest 管理员登录请求。
|
||||||
|
type AdminLoginRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,max=32"`
|
||||||
|
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminInfo 管理员信息。
|
||||||
|
type AdminInfo struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
Role int8 `json:"role"` // 1=超级管理员 2=普通管理员
|
||||||
|
Status int8 `json:"status"` // 1=启用 2=禁用
|
||||||
|
LastLoginTime int64 `json:"last_login_time"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminLoginResponse 登录响应(双 token + 管理员信息)。
|
||||||
|
type AdminLoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(秒)
|
||||||
|
AdminInfo AdminInfo `json:"admin_info"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRefreshTokenRequest 刷新 token 请求。
|
||||||
|
type AdminRefreshTokenRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
AccessToken string `json:"access_token"` // 可选:传入后立即吊销旧 access token
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminRefreshTokenResponse 刷新 token 响应。
|
||||||
|
type AdminRefreshTokenResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpireTime int64 `json:"expire_time"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminChangePasswordRequest 修改自己密码请求。
|
||||||
|
type AdminChangePasswordRequest struct {
|
||||||
|
OldPassword string `json:"old_password" binding:"required,min=6,max=64"`
|
||||||
|
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListRequest 管理员列表请求。
|
||||||
|
type AdminListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2"` // 1=启用 2=禁用;不传=全部
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminListResponse 管理员列表响应。
|
||||||
|
type AdminListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []AdminInfo `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreateRequest 创建管理员请求(仅超级管理员)。
|
||||||
|
type AdminCreateRequest struct {
|
||||||
|
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||||
|
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||||
|
Nickname string `json:"nickname" binding:"omitempty,max=32"`
|
||||||
|
Role int8 `json:"role" binding:"required,oneof=1 2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdateRequest 更新管理员请求(仅超级管理员;传什么改什么)。
|
||||||
|
type AdminUpdateRequest struct {
|
||||||
|
Nickname string `json:"nickname" binding:"omitempty,max=32"`
|
||||||
|
Password string `json:"password" binding:"omitempty,min=6,max=64"`
|
||||||
|
Role int8 `json:"role" binding:"omitempty,oneof=1 2"`
|
||||||
|
Status int8 `json:"status" binding:"omitempty,oneof=1 2"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// PageRequest 分页请求参数(GET query,嵌入各列表请求)
|
||||||
|
type PageRequest struct {
|
||||||
|
Page int `form:"page" binding:"omitempty,min=1"`
|
||||||
|
PageSize int `form:"page_size" binding:"omitempty,min=1,max=50"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PageResponse 分页响应(嵌入各列表响应)
|
||||||
|
type PageResponse struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
Page int `json:"page"`
|
||||||
|
PageSize int `json:"page_size"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// OutfitListRequest 穿搭列表请求。
|
||||||
|
type OutfitListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
UserID int64 `form:"user_id"`
|
||||||
|
IsPublic int8 `form:"is_public" binding:"omitempty,oneof=1 2"`
|
||||||
|
AuditStatus int8 `form:"audit_status" binding:"omitempty,oneof=1 2 3"`
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitListItem 穿搭列表项。
|
||||||
|
type OutfitListItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
OutfitUUID string `json:"outfit_uuid"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
OutfitName string `json:"outfit_name"`
|
||||||
|
OutfitDesc string `json:"outfit_desc"`
|
||||||
|
Scene string `json:"scene"`
|
||||||
|
Source int8 `json:"source"` // 1=用户创建 2=AI 推荐
|
||||||
|
IsPublic int8 `json:"is_public"` // 1=不公开 2=公开
|
||||||
|
AuditStatus int8 `json:"audit_status"` // 1=待审核 2=审核通过 3=审核不通过
|
||||||
|
Status int8 `json:"status"` // 1=正常 2=封禁 3=待用户保存
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitListResponse 穿搭列表响应。
|
||||||
|
type OutfitListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []OutfitListItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitClothesItem 穿搭衣物明细项。
|
||||||
|
type OutfitClothesItem struct {
|
||||||
|
ClothesUUID string `json:"clothes_uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryMain string `json:"category_main"`
|
||||||
|
CategorySub string `json:"category_sub"`
|
||||||
|
ProcessedImage string `json:"processed_image"`
|
||||||
|
ImageProcessStatus int8 `json:"image_process_status"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitDetailResponse 穿搭详情响应。
|
||||||
|
type OutfitDetailResponse struct {
|
||||||
|
OutfitListItem
|
||||||
|
Items []OutfitClothesItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitAuditRequest 穿搭审核请求。
|
||||||
|
type OutfitAuditRequest struct {
|
||||||
|
Result int8 `json:"result" binding:"required,oneof=1 2"` // 1=通过 2=驳回
|
||||||
|
Reason string `json:"reason" binding:"omitempty,max=200"` // 驳回原因(result=2 时建议填写)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitBanRequest 穿搭封禁/解封请求。
|
||||||
|
type OutfitBanRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"omitempty,max=200"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesListRequest 衣物列表请求。
|
||||||
|
type ClothesListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
UserID int64 `form:"user_id"`
|
||||||
|
ImageProcessStatus int8 `form:"image_process_status" binding:"omitempty,oneof=1 2 3 4 5"`
|
||||||
|
CategoryMain string `form:"category_main"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesItem 衣物信息。
|
||||||
|
type ClothesItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ClothesUUID string `json:"clothes_uuid"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
CategoryMain string `json:"category_main"`
|
||||||
|
CategorySub string `json:"category_sub"`
|
||||||
|
Style string `json:"style"`
|
||||||
|
ColorMain string `json:"color_main"`
|
||||||
|
ColorSub string `json:"color_sub"`
|
||||||
|
Thickness string `json:"thickness"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
Material string `json:"material"`
|
||||||
|
Pattern string `json:"pattern"`
|
||||||
|
SleeveType string `json:"sleeve_type"`
|
||||||
|
ClothesType string `json:"clothes_type"`
|
||||||
|
NecklineType string `json:"neckline_type"`
|
||||||
|
Formality string `json:"formality"`
|
||||||
|
Occasions string `json:"occasions"`
|
||||||
|
VisualImpact string `json:"visual_impact"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
ProcessedImage string `json:"processed_image"`
|
||||||
|
ImageSize int64 `json:"image_size"`
|
||||||
|
ImageProcessStatus int8 `json:"image_process_status"`
|
||||||
|
FailReason string `json:"fail_reason"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesListResponse 衣物列表响应。
|
||||||
|
type ClothesListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []ClothesItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareListRequest 衣柜分享列表请求。
|
||||||
|
type ClosetShareListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
UserID int64 `form:"user_id"`
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareItem 衣柜分享信息。
|
||||||
|
type ClosetShareItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
ShareUUID string `json:"share_uuid"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
ShareType int8 `json:"share_type"` // 1=部分衣物分享 2=全量快照分享
|
||||||
|
ExpireTime int64 `json:"expire_time"`
|
||||||
|
Status int8 `json:"status"` // 1=正常 2=已取消 3=已过期 4=已封禁
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareListResponse 衣柜分享列表响应。
|
||||||
|
type ClosetShareListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []ClosetShareItem `json:"list"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// DashboardResponse 仪表盘统计响应。
|
||||||
|
type DashboardResponse struct {
|
||||||
|
Users DashboardUsers `json:"users"`
|
||||||
|
Orders DashboardOrders `json:"orders"`
|
||||||
|
Revenue DashboardRevenue `json:"revenue"`
|
||||||
|
Todo DashboardTodo `json:"todo"`
|
||||||
|
Content DashboardContent `json:"content"`
|
||||||
|
VIP DashboardVIP `json:"vip"`
|
||||||
|
Goods DashboardGoods `json:"goods"`
|
||||||
|
Usage DashboardUsage `json:"usage"`
|
||||||
|
Social DashboardSocial `json:"social"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardUsers 用户统计。
|
||||||
|
type DashboardUsers struct {
|
||||||
|
Total int64 `json:"total"` // 用户总数
|
||||||
|
TodayNew int64 `json:"today_new"` // 今日新增
|
||||||
|
Normal int64 `json:"normal"` // 正常
|
||||||
|
TempBan int64 `json:"temp_ban"` // 临时禁用
|
||||||
|
PermBan int64 `json:"perm_ban"` // 永久禁用
|
||||||
|
Deactivated int64 `json:"deactivated"` // 已注销
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardOrders 订单统计。
|
||||||
|
type DashboardOrders struct {
|
||||||
|
Total int64 `json:"total"` // 订单总数
|
||||||
|
Pending int64 `json:"pending"` // 待支付
|
||||||
|
Paid int64 `json:"paid"` // 已支付
|
||||||
|
Cancelled int64 `json:"cancelled"` // 已取消
|
||||||
|
Expired int64 `json:"expired"` // 已过期
|
||||||
|
Refunded int64 `json:"refunded"` // 已退款
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardRevenue 营收统计(单位:分)。
|
||||||
|
type DashboardRevenue struct {
|
||||||
|
TotalRevenue int64 `json:"total_revenue"` // 累计营收
|
||||||
|
TodayRevenue int64 `json:"today_revenue"` // 今日营收
|
||||||
|
RefundTotal int64 `json:"refund_total"` // 累计退款
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardTodo 待办统计。
|
||||||
|
type DashboardTodo struct {
|
||||||
|
RefundPending int64 `json:"refund_pending"` // 待处理退款
|
||||||
|
OutfitPendingAudit int64 `json:"outfit_pending_audit"` // 待审核穿搭
|
||||||
|
GoodsOnShelf int64 `json:"goods_on_shelf"` // 上架商品数
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 内容审核概况 =====
|
||||||
|
|
||||||
|
// DashboardContent 内容审核概况。
|
||||||
|
type DashboardContent struct {
|
||||||
|
Outfit DashboardOutfitStats `json:"outfit"`
|
||||||
|
Clothes DashboardClothesStats `json:"clothes"`
|
||||||
|
Share DashboardShareStats `json:"share"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardOutfitStats 穿搭审核统计。
|
||||||
|
type DashboardOutfitStats struct {
|
||||||
|
Total int64 `json:"total"` // 穿搭总数
|
||||||
|
Pending int64 `json:"pending"` // 待审核
|
||||||
|
Passed int64 `json:"passed"` // 审核通过
|
||||||
|
Rejected int64 `json:"rejected"` // 审核不通过
|
||||||
|
Banned int64 `json:"banned"` // 封禁
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardClothesStats 衣物图片处理统计。
|
||||||
|
type DashboardClothesStats struct {
|
||||||
|
Total int64 `json:"total"` // 衣物总数
|
||||||
|
Processing int64 `json:"processing"` // 处理中(抠图/分类/排队)
|
||||||
|
Success int64 `json:"success"` // 处理成功
|
||||||
|
Failed int64 `json:"failed"` // 处理失败
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardShareStats 衣柜分享统计。
|
||||||
|
type DashboardShareStats struct {
|
||||||
|
Total int64 `json:"total"` // 分享总数
|
||||||
|
Normal int64 `json:"normal"` // 正常
|
||||||
|
Cancelled int64 `json:"cancelled"` // 用户取消
|
||||||
|
Expired int64 `json:"expired"` // 已过期
|
||||||
|
Banned int64 `json:"banned"` // 管理员封禁
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VIP 会员概况 =====
|
||||||
|
|
||||||
|
// DashboardVIP 会员概况。
|
||||||
|
type DashboardVIP struct {
|
||||||
|
MemberTotal int64 `json:"member_total"` // 有效会员总数(月度/年度/体验)
|
||||||
|
Monthly int64 `json:"monthly"` // 月度会员
|
||||||
|
Yearly int64 `json:"yearly"` // 年度会员
|
||||||
|
Trial int64 `json:"trial"` // 体验会员
|
||||||
|
ExpiringSoon int64 `json:"expiring_soon"` // 7天内即将到期
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 商品分类统计 =====
|
||||||
|
|
||||||
|
// DashboardGoods 商品统计。
|
||||||
|
type DashboardGoods struct {
|
||||||
|
Total int64 `json:"total"`
|
||||||
|
OnShelf int64 `json:"on_shelf"`
|
||||||
|
OffShelf int64 `json:"off_shelf"`
|
||||||
|
ByType []GoodsTypeCount `json:"by_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsTypeCount 商品类型数量。
|
||||||
|
type GoodsTypeCount struct {
|
||||||
|
Type int8 `json:"type"` // 1会员包 2上传包 3AI推荐包 4衣柜分享包 5搭配分享包
|
||||||
|
Count int64 `json:"count"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 趋势 =====
|
||||||
|
|
||||||
|
// TrendRequest 趋势查询参数。
|
||||||
|
type TrendRequest struct {
|
||||||
|
Days int `form:"days" binding:"omitempty,min=1,max=90"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendResponse 趋势响应。
|
||||||
|
type TrendResponse struct {
|
||||||
|
Points []TrendPoint `json:"points"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendPoint 单日趋势点。
|
||||||
|
type TrendPoint struct {
|
||||||
|
Date string `json:"date"` // YYYY-MM-DD
|
||||||
|
NewUsers int64 `json:"new_users"` // 新增用户
|
||||||
|
Orders int64 `json:"orders"` // 已支付订单量
|
||||||
|
Revenue int64 `json:"revenue"` // 营收(分)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 今日消耗与互动 =====
|
||||||
|
|
||||||
|
// DashboardUsage 今日消耗统计。
|
||||||
|
type DashboardUsage struct {
|
||||||
|
AIRecommend int64 `json:"ai_recommend"` // 今日AI推荐消耗次数
|
||||||
|
ClosetShare int64 `json:"closet_share"` // 今日衣柜分享消耗次数
|
||||||
|
OutfitShare int64 `json:"outfit_share"` // 今日社区分享消耗次数
|
||||||
|
}
|
||||||
|
|
||||||
|
// DashboardSocial 内容互动统计。
|
||||||
|
type DashboardSocial struct {
|
||||||
|
CosImageCount int64 `json:"cos_image_count"` // COS总图数
|
||||||
|
LikeCount int64 `json:"like_count"` // 点赞数
|
||||||
|
CollectCount int64 `json:"collect_count"` // 收藏数
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// GoodsListRequest 商品列表请求。
|
||||||
|
type GoodsListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
GoodsType int8 `form:"goods_type" binding:"omitempty,oneof=1 2 3 4 5"`
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2"` // 1=上架 2=下架;不传=全部
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsItem 商品信息。
|
||||||
|
type GoodsItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
GoodsCode string `json:"goods_code"`
|
||||||
|
GoodsName string `json:"goods_name"`
|
||||||
|
GoodsType int8 `json:"goods_type"` // 1=会员包 2=上传包 3=AI 推荐包 4=衣柜分享包 5=搭配分享包
|
||||||
|
Price int64 `json:"price"` // 原价(分)
|
||||||
|
DiscountPrice int64 `json:"discount_price"`
|
||||||
|
MemberDays int64 `json:"member_days"`
|
||||||
|
UploadQuota int64 `json:"upload_quota"` // 上传额度(对应业务表 cos_quota)
|
||||||
|
AIRecommendQuota int64 `json:"ai_recommend_quota"` // AI 推荐额度
|
||||||
|
ClosetShareQuota int64 `json:"closet_share_quota"` // 衣柜分享额度
|
||||||
|
OutfitShareQuota int64 `json:"outfit_share_quota"` // 搭配分享额度
|
||||||
|
ValidDays int64 `json:"valid_days"` // 赠送额度有效期天数(加量包;会员包 0=跟随 VIP)
|
||||||
|
Description string `json:"description"`
|
||||||
|
Status int8 `json:"status"` // 1=上架 2=下架
|
||||||
|
ProductID string `json:"product_id"` // 虚拟支付道具ID(只读,空=未发布)
|
||||||
|
ProductStatus int8 `json:"product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败(只读)
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
UpdatedAt int64 `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsListResponse 商品列表响应。
|
||||||
|
type GoodsListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []GoodsItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsCreateRequest 创建商品请求。
|
||||||
|
type GoodsCreateRequest struct {
|
||||||
|
GoodsCode string `json:"goods_code" binding:"required,max=64"`
|
||||||
|
GoodsName string `json:"goods_name" binding:"required,max=64"`
|
||||||
|
GoodsType int8 `json:"goods_type" binding:"required,oneof=1 2 3 4 5"`
|
||||||
|
Price int64 `json:"price" binding:"required,min=0,max=100000000"`
|
||||||
|
DiscountPrice int64 `json:"discount_price" binding:"required,min=0,max=100000000"`
|
||||||
|
MemberDays int64 `json:"member_days" binding:"omitempty,min=0"`
|
||||||
|
UploadQuota int64 `json:"upload_quota" binding:"omitempty,min=0"`
|
||||||
|
AIRecommendQuota int64 `json:"ai_recommend_quota" binding:"omitempty,min=0"`
|
||||||
|
ClosetShareQuota int64 `json:"closet_share_quota" binding:"omitempty,min=0"`
|
||||||
|
OutfitShareQuota int64 `json:"outfit_share_quota" binding:"omitempty,min=0"`
|
||||||
|
ValidDays int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"`
|
||||||
|
Description string `json:"description" binding:"omitempty,max=500"`
|
||||||
|
Status int8 `json:"status" binding:"omitempty,oneof=1 2"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsUpdateRequest 更新商品请求(传什么改什么)。
|
||||||
|
type GoodsUpdateRequest struct {
|
||||||
|
GoodsName *string `json:"goods_name" binding:"omitempty,max=64"`
|
||||||
|
Price *int64 `json:"price" binding:"omitempty,min=0,max=100000000"`
|
||||||
|
DiscountPrice *int64 `json:"discount_price" binding:"omitempty,min=0,max=100000000"`
|
||||||
|
MemberDays *int64 `json:"member_days" binding:"omitempty,min=0"`
|
||||||
|
UploadQuota *int64 `json:"upload_quota" binding:"omitempty,min=0"`
|
||||||
|
AIRecommendQuota *int64 `json:"ai_recommend_quota" binding:"omitempty,min=0"`
|
||||||
|
ClosetShareQuota *int64 `json:"closet_share_quota" binding:"omitempty,min=0"`
|
||||||
|
OutfitShareQuota *int64 `json:"outfit_share_quota" binding:"omitempty,min=0"`
|
||||||
|
ValidDays *int64 `json:"valid_days" binding:"omitempty,min=0,max=3650"`
|
||||||
|
Description *string `json:"description" binding:"omitempty,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsStatusRequest 商品上下架请求。
|
||||||
|
type GoodsStatusRequest struct {
|
||||||
|
Status int8 `json:"status" binding:"required,oneof=1 2"` // 1=上架 2=下架
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// NotificationSendRequest 单用户通知请求。
|
||||||
|
type NotificationSendRequest struct {
|
||||||
|
UserID int64 `json:"user_id" binding:"required,min=1"`
|
||||||
|
Title string `json:"title" binding:"required,max=64"`
|
||||||
|
Content string `json:"content" binding:"required,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationBroadcastRequest 全量广播请求。
|
||||||
|
type NotificationBroadcastRequest struct {
|
||||||
|
Title string `json:"title" binding:"required,max=64"`
|
||||||
|
Content string `json:"content" binding:"required,max=500"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationBroadcastResponse 广播结果。
|
||||||
|
type NotificationBroadcastResponse struct {
|
||||||
|
SentCount int64 `json:"sent_count"` // 已发送用户数
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// OrderListRequest 订单列表请求。
|
||||||
|
type OrderListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
OrderNo string `form:"order_no"` // 订单号(精确)
|
||||||
|
UserID int64 `form:"user_id"` // 用户 ID
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4 5"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderItem 订单信息(金额单位:分)。
|
||||||
|
type OrderItem struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
GoodsID int64 `json:"goods_id"`
|
||||||
|
GoodsName string `json:"goods_name"`
|
||||||
|
UnitPrice int64 `json:"unit_price"`
|
||||||
|
TotalAmount int64 `json:"total_amount"`
|
||||||
|
PayAmount int64 `json:"pay_amount"`
|
||||||
|
PayChannel int8 `json:"pay_channel"` // 1=微信支付 2=微信虚拟支付
|
||||||
|
OrderStatus int8 `json:"order_status"`
|
||||||
|
TransactionID string `json:"transaction_id"`
|
||||||
|
MemberDays int64 `json:"member_days"`
|
||||||
|
UploadQuota int64 `json:"upload_quota"`
|
||||||
|
AIRecommendQuota int64 `json:"ai_recommend_quota"`
|
||||||
|
ClosetShareQuota int64 `json:"closet_share_quota"`
|
||||||
|
OutfitShareQuota int64 `json:"outfit_share_quota"`
|
||||||
|
ExpireTime int64 `json:"expire_time"`
|
||||||
|
PayTime int64 `json:"pay_time"`
|
||||||
|
RefundTime int64 `json:"refund_time"`
|
||||||
|
RefundAmount int64 `json:"refund_amount"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
XPayProductID string `json:"xpay_product_id"` // 虚拟支付道具ID(pay_channel=2 时使用)
|
||||||
|
XPayPlatform string `json:"xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
|
||||||
|
XPayEnv int8 `json:"xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderListResponse 订单列表响应。
|
||||||
|
type OrderListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []OrderItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundListRequest 退款单列表请求。
|
||||||
|
// Status 用指针区分「未传(nil,不过滤)」与「显式传 0(筛选待处理)」。
|
||||||
|
type RefundListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
Status *int8 `form:"status" binding:"omitempty,oneof=0 1 2 3"` // 0=待处理 1=退款中 2=成功 3=失败;不传=全部
|
||||||
|
OrderNo string `form:"order_no"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundItem 退款单信息。
|
||||||
|
type RefundItem struct {
|
||||||
|
RefundNo string `json:"refund_no"`
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
|
UserID int64 `json:"user_id"`
|
||||||
|
RefundAmount int64 `json:"refund_amount"`
|
||||||
|
RefundReason string `json:"refund_reason"`
|
||||||
|
Status int8 `json:"status"` // 0=待处理 1=退款中 2=退款成功 3=退款失败
|
||||||
|
ApplyTime int64 `json:"apply_time"`
|
||||||
|
SuccessTime int64 `json:"success_time"`
|
||||||
|
FailReason string `json:"fail_reason"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundListResponse 退款单列表响应。
|
||||||
|
type RefundListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []RefundItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundAuditRequest 退款审核请求。
|
||||||
|
type RefundAuditRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"required,max=200"` // 通过=审核备注;驳回=驳回原因
|
||||||
|
}
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package dto
|
||||||
|
|
||||||
|
// UserListRequest 用户列表请求。
|
||||||
|
type UserListRequest struct {
|
||||||
|
PageRequest
|
||||||
|
Keyword string `form:"keyword"` // 模糊匹配昵称/手机号/OpenID
|
||||||
|
Status int8 `form:"status" binding:"omitempty,oneof=1 2 3 4"` // 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserListItem 用户列表项。
|
||||||
|
type UserListItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
UserUUID string `json:"user_uuid"`
|
||||||
|
Username string `json:"username"`
|
||||||
|
Avatar string `json:"avatar"`
|
||||||
|
Sex int8 `json:"sex"`
|
||||||
|
Phone string `json:"phone"`
|
||||||
|
Status int8 `json:"status"` // 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||||
|
BanReason string `json:"ban_reason"`
|
||||||
|
BanStart int64 `json:"ban_start"`
|
||||||
|
BanExpire int64 `json:"ban_expire"`
|
||||||
|
VIPType int8 `json:"vip_type"` // 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||||
|
VIPExpire int64 `json:"vip_expire"` // VIP 到期时间(秒),已过期时归一为 0
|
||||||
|
LastLoginTime int64 `json:"last_login_time"`
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserListResponse 用户列表响应。
|
||||||
|
type UserListResponse struct {
|
||||||
|
PageResponse
|
||||||
|
List []UserListItem `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaItem 用户某类额度。
|
||||||
|
// Cos(quota_type=1):Total=永久总额度、Used=永久累计已用、Remain=永久剩余;
|
||||||
|
// AI/社区/衣柜(2/3/4):Total=每日总额度、Used=当日已用、Remain=当日剩余(次日 0 点重置)。
|
||||||
|
type QuotaItem struct {
|
||||||
|
QuotaType int8 `json:"quota_type"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
|
||||||
|
Total int64 `json:"total"` // 总额度(Cos 永久 / 每日额度)
|
||||||
|
Used int64 `json:"used"` // 已用(Cos 永久累计 / 当日已用)
|
||||||
|
Remain int64 `json:"remain"` // 剩余
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGrantItem 用户额度凭证(有有效期赠送额度)。
|
||||||
|
type UserGrantItem struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
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=广告激励
|
||||||
|
ExpireTime int64 `json:"expire_time"` // 到期时间(秒,0=跟随 VIP)
|
||||||
|
RefID string `json:"ref_id"` // 关联ID(订单号/广告记录ID/人工发放标识)
|
||||||
|
CreatedAt int64 `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDetailResponse 用户详情响应。
|
||||||
|
type UserDetailResponse struct {
|
||||||
|
UserListItem
|
||||||
|
Quotas []QuotaItem `json:"quotas"`
|
||||||
|
Grants []UserGrantItem `json:"grants"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserBanRequest 封禁用户请求。
|
||||||
|
type UserBanRequest struct {
|
||||||
|
BanType int8 `json:"ban_type" binding:"required,oneof=1 2"` // 1=临时禁用 2=永久禁用
|
||||||
|
BanDays int `json:"ban_days" binding:"omitempty,min=1,max=3650"` // 临时禁用天数(ban_type=1 时必填)
|
||||||
|
Reason string `json:"reason" binding:"required,max=200"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUnbanRequest 解封用户请求。
|
||||||
|
type UserUnbanRequest struct {
|
||||||
|
Reason string `json:"reason" binding:"omitempty,max=200"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserQuotaAdjustRequest 额度发放请求(发放有有效期额度凭证)。
|
||||||
|
// Cos(quota_type=1):Amount 为一次性库存,ExpireDays 天内有效;
|
||||||
|
// AI/社区/衣柜(2/3/4):Amount 为有效期内每天额外赠送次数,ExpireDays 天内有效。
|
||||||
|
type UserQuotaAdjustRequest struct {
|
||||||
|
QuotaType int8 `json:"quota_type" binding:"required,oneof=1 2 3 4"` // 1=上传 2=AI 3=社区分享 4=衣柜分享
|
||||||
|
Amount int64 `json:"amount" binding:"required,min=1,max=100000000"` // 发放数量
|
||||||
|
ExpireDays int64 `json:"expire_days" binding:"required,min=1,max=3650"` // 有效期天数
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserVIPAdjustRequest VIP 人工调整请求。
|
||||||
|
// 叠加语义(与购买发放一致):vip_expire = max(当前到期, now) + vip_days*86400。
|
||||||
|
// vip_days=0 表示取消会员(vip_type 归 1、到期清零)。
|
||||||
|
type UserVIPAdjustRequest struct {
|
||||||
|
VIPType int8 `json:"vip_type" binding:"required,oneof=1 2 3 4"` // 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||||
|
VIPDays int64 `json:"vip_days" binding:"omitempty,min=0,max=3650"`
|
||||||
|
Reason string `json:"reason" binding:"required,max=200"`
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CORS 跨域中间件。
|
||||||
|
// 允许来源通过 Server.Domain 配置,自动放行其 http/https 两种协议的 Origin。
|
||||||
|
// 未配置时回退为 *(保持兼容)。
|
||||||
|
func CORS() gin.HandlerFunc {
|
||||||
|
domain := ""
|
||||||
|
if global.ServerSetting != nil {
|
||||||
|
domain = strings.TrimSpace(global.ServerSetting.Domain)
|
||||||
|
}
|
||||||
|
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
origin := c.GetHeader("Origin")
|
||||||
|
allowOrigin := "*"
|
||||||
|
if domain != "" && domain != "*" {
|
||||||
|
allowOrigin = ""
|
||||||
|
if originAllowed(origin, domain) {
|
||||||
|
allowOrigin = origin
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allowOrigin != "" {
|
||||||
|
c.Header("Access-Control-Allow-Origin", allowOrigin)
|
||||||
|
}
|
||||||
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
|
||||||
|
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Authorization")
|
||||||
|
c.Header("Access-Control-Expose-Headers", "Content-Length")
|
||||||
|
c.Header("Access-Control-Max-Age", "86400")
|
||||||
|
|
||||||
|
if c.Request.Method == "OPTIONS" {
|
||||||
|
c.AbortWithStatus(204)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// originAllowed 判断 Origin 是否命中配置域名(http/https,忽略尾部斜杠)。
|
||||||
|
func originAllowed(origin, domain string) bool {
|
||||||
|
o := strings.TrimSuffix(strings.TrimSpace(origin), "/")
|
||||||
|
return o == "https://"+domain || o == "http://"+domain
|
||||||
|
}
|
||||||
@@ -0,0 +1,137 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ctxKey string
|
||||||
|
|
||||||
|
const (
|
||||||
|
adminIDKey ctxKey = "adminID"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// adminRoleSuper 超级管理员角色(账号管理接口专用)。
|
||||||
|
adminRoleSuper int8 = 1
|
||||||
|
// adminStatusEnabled 启用状态。
|
||||||
|
adminStatusEnabled int8 = 1
|
||||||
|
)
|
||||||
|
|
||||||
|
// JWTAuth 管理员鉴权中间件:
|
||||||
|
// 解析 Bearer token → Redis 会话校验(失败 fail-open)→ 数据库状态回查(禁用即拒绝)→ AdminID 注入 context。
|
||||||
|
func JWTAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
|
||||||
|
// 1. 从 Authorization 头取 token:Bearer xxx.yyy.zzz
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("invalid Authorization format"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解析 token
|
||||||
|
claims, err := lzcwyjwt.ParseAccessToken(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, lzcwyjwt.ErrTokenExpired):
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
|
||||||
|
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
|
||||||
|
default:
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
|
||||||
|
}
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2.5 校验 access token 是否仍在会话中(登出/失效后立即拒绝)。
|
||||||
|
sessionID, serr := redisdao.AuthAdminIDByAccess(c.Request.Context(), parts[1])
|
||||||
|
if serr != nil {
|
||||||
|
if errors.Is(serr, redis.Nil) {
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token session invalid"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Redis 其它错误降级放行(fail-open):优先保证可用性;
|
||||||
|
// 被禁用/删除的管理员仍会被下方 DB 回查拒绝,不会因本降级放行而绕过。
|
||||||
|
slog.WarnContext(c.Request.Context(), "check admin token session failed, allow by fallback", "error", serr)
|
||||||
|
} else if sessionID != claims.AdminID {
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token user mismatch"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 数据库回查管理员状态(禁用/删除立即生效)。
|
||||||
|
admin, dbErr := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), claims.AdminID)
|
||||||
|
if dbErr != nil {
|
||||||
|
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("管理员不存在"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if admin.Status != adminStatusEnabled {
|
||||||
|
response.ToErrorResponse(errcode.AdminDisabled)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 把 adminID 注入 context
|
||||||
|
ctx := context.WithValue(c.Request.Context(), adminIDKey, claims.AdminID)
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSuperAuth 超级管理员权限校验(在 JWTAuth 之后使用)。
|
||||||
|
func AdminSuperAuth() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
|
||||||
|
adminID, ok := GetAdminID(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
response.ToErrorResponse(errcode.Unauthorized)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin, err := mysqldao.NewAdminDao().AdminGetByID(c.Request.Context(), adminID)
|
||||||
|
if err != nil {
|
||||||
|
response.ToErrorResponse(errcode.AdminNotFound)
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if admin.Role != adminRoleSuper {
|
||||||
|
response.ToErrorResponse(errcode.Forbidden.WithDetails("仅超级管理员可操作"))
|
||||||
|
c.Abort()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdminID 从 context 取登录管理员 ID(在 handler 里用)。
|
||||||
|
func GetAdminID(ctx context.Context) (int64, bool) {
|
||||||
|
uid, ok := ctx.Value(adminIDKey).(int64)
|
||||||
|
return uid, ok
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// sanitizeQuery 对 query 中的敏感参数值脱敏,避免日志泄露 token/code/密钥等。
|
||||||
|
func sanitizeQuery(query string) string {
|
||||||
|
if query == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
values, err := url.ParseQuery(query)
|
||||||
|
if err != nil {
|
||||||
|
return "***"
|
||||||
|
}
|
||||||
|
sensitive := map[string]bool{
|
||||||
|
"token": true, "code": true, "secret": true, "password": true,
|
||||||
|
"access_token": true, "refresh_token": true, "key": true,
|
||||||
|
}
|
||||||
|
for k := range values {
|
||||||
|
if sensitive[strings.ToLower(k)] {
|
||||||
|
values.Set(k, "***")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
|
func GinSlogMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
path := c.Request.URL.Path
|
||||||
|
query := sanitizeQuery(c.Request.URL.RawQuery)
|
||||||
|
|
||||||
|
// 处理请求
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// 请求结束后打日志
|
||||||
|
slog.InfoContext(c.Request.Context(), "GIN_REQUEST",
|
||||||
|
"request_id", GetRequestID(c.Request.Context()),
|
||||||
|
"method", c.Request.Method,
|
||||||
|
"path", path,
|
||||||
|
"query", query,
|
||||||
|
"status", c.Writer.Status(),
|
||||||
|
"ip", c.ClientIP(),
|
||||||
|
"latency", time.Since(start).String(),
|
||||||
|
"user_agent", c.Request.UserAgent(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GinOTelMiddleware 为每个 HTTP 请求自动开启 server span。
|
||||||
|
func GinOTelMiddleware() gin.HandlerFunc {
|
||||||
|
name := "zt-closet-manage-backend"
|
||||||
|
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
|
||||||
|
name = global.OtelSetting.ServiceName
|
||||||
|
}
|
||||||
|
return otelgin.Middleware(name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package middleware
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
const requestIDKey ctxKey = "requestID"
|
||||||
|
|
||||||
|
// RequestID 从请求头取 X-Request-ID,没有则生成 UUID。
|
||||||
|
// 注入 context 并写入响应头,便于全链路追踪。
|
||||||
|
func RequestID() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
rid := c.GetHeader("X-Request-ID")
|
||||||
|
if rid == "" {
|
||||||
|
rid = uuid.New().String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入 gin context(供 handler 取用)
|
||||||
|
c.Set("requestID", rid)
|
||||||
|
// 写入 request context(供 service/dao 的 slog 取用)
|
||||||
|
ctx := context.WithValue(c.Request.Context(), requestIDKey, rid)
|
||||||
|
c.Request = c.Request.WithContext(ctx)
|
||||||
|
// 响应头
|
||||||
|
c.Header("X-Request-ID", rid)
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRequestID 从 context 取出 request-id。
|
||||||
|
func GetRequestID(ctx context.Context) string {
|
||||||
|
rid, _ := ctx.Value(requestIDKey).(string)
|
||||||
|
return rid
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminDao 管理员账号数据访问
|
||||||
|
type AdminDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminDao 创建管理员 DAO
|
||||||
|
func NewAdminDao() *AdminDao {
|
||||||
|
return &AdminDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetByID 按主键 ID 查询管理员
|
||||||
|
func (d *AdminDao) AdminGetByID(ctx context.Context, id int64) (*mysqlmodel.AdminUser, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminGetByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var admin mysqlmodel.AdminUser
|
||||||
|
if err := d.db.WithContext(ctx).First(&admin, id).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminGetByUsername 按登录账号查询管理员(含软删除)
|
||||||
|
func (d *AdminDao) AdminGetByUsername(ctx context.Context, username string) (*mysqlmodel.AdminUser, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminGetByUsername")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var admin mysqlmodel.AdminUser
|
||||||
|
if err := d.db.WithContext(ctx).Where("username = ?", username).First(&admin).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &admin, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminList 分页查询管理员列表(status 为 0 不筛选)
|
||||||
|
func (d *AdminDao) AdminList(ctx context.Context, status int8, page, pageSize int) ([]mysqlmodel.AdminUser, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.AdminUser
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.AdminUser{})
|
||||||
|
if status != 0 {
|
||||||
|
q = q.Where("status = ?", status)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((page - 1) * pageSize).Limit(pageSize).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminCreate 创建管理员账号
|
||||||
|
func (d *AdminDao) AdminCreate(ctx context.Context, admin *mysqlmodel.AdminUser) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminCreate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Create(admin).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminUpdate 按主键更新管理员字段
|
||||||
|
func (d *AdminDao) AdminUpdate(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminUpdate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.AdminUser{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSoftDelete 软删除管理员账号
|
||||||
|
func (d *AdminDao) AdminSoftDelete(ctx context.Context, id int64) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.AdminSoftDelete")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Where("id = ?", id).Delete(&mysqlmodel.AdminUser{}).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClosetShareDao 衣柜分享数据访问
|
||||||
|
type ClosetShareDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClosetShareDao 创建衣柜分享 DAO
|
||||||
|
func NewClosetShareDao() *ClosetShareDao {
|
||||||
|
return &ClosetShareDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareListParams 分享分页列表筛选参数。
|
||||||
|
type ClosetShareListParams struct {
|
||||||
|
UserID int64 // 用户 ID,0 表示全部
|
||||||
|
Status int8 // 分享状态,0 表示全部
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareList 分页查询衣柜分享
|
||||||
|
func (d *ClosetShareDao) ClosetShareList(ctx context.Context, p ClosetShareListParams) ([]mysqlmodel.ClosetShare, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.ClosetShare
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{})
|
||||||
|
if p.UserID != 0 {
|
||||||
|
q = q.Where("user_id = ?", p.UserID)
|
||||||
|
}
|
||||||
|
if p.Status != 0 {
|
||||||
|
q = q.Where("status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareGetByUUID 按分享 UUID 查询
|
||||||
|
func (d *ClosetShareDao) ClosetShareGetByUUID(ctx context.Context, shareUUID string) (*mysqlmodel.ClosetShare, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareGetByUUID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var share mysqlmodel.ClosetShare
|
||||||
|
if err := d.db.WithContext(ctx).Where("share_uuid = ?", shareUUID).First(&share).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &share, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClosetShareUpdateStatus 更新分享状态(封禁/解封)
|
||||||
|
func (d *ClosetShareDao) ClosetShareUpdateStatus(ctx context.Context, shareUUID string, status int8) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClosetShareUpdateStatus")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).
|
||||||
|
Where("share_uuid = ?", shareUUID).
|
||||||
|
UpdateColumn("status", status).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClothesDao 衣物数据访问
|
||||||
|
type ClothesDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClothesDao 创建衣物 DAO
|
||||||
|
func NewClothesDao() *ClothesDao {
|
||||||
|
return &ClothesDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesListParams 衣物分页列表筛选参数。
|
||||||
|
type ClothesListParams struct {
|
||||||
|
UserID int64 // 用户 ID,0 表示全部
|
||||||
|
ImageProcessStatus int8 // AI 处理状态,0 表示全部
|
||||||
|
CategoryMain string // 大类筛选
|
||||||
|
Keyword string // 模糊匹配名称
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesList 分页查询衣物
|
||||||
|
func (d *ClothesDao) ClothesList(ctx context.Context, p ClothesListParams) ([]mysqlmodel.Clothes, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.Clothes
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{})
|
||||||
|
if p.UserID != 0 {
|
||||||
|
q = q.Where("user_id = ?", p.UserID)
|
||||||
|
}
|
||||||
|
if p.ImageProcessStatus != 0 {
|
||||||
|
q = q.Where("image_process_status = ?", p.ImageProcessStatus)
|
||||||
|
}
|
||||||
|
if p.CategoryMain != "" {
|
||||||
|
q = q.Where("category_main = ?", p.CategoryMain)
|
||||||
|
}
|
||||||
|
if p.Keyword != "" {
|
||||||
|
q = q.Where("name LIKE ?", "%"+p.Keyword+"%")
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesGetByUUID 按衣物 UUID 查询
|
||||||
|
func (d *ClothesDao) ClothesGetByUUID(ctx context.Context, clothesUUID string) (*mysqlmodel.Clothes, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesGetByUUID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var clothes mysqlmodel.Clothes
|
||||||
|
if err := d.db.WithContext(ctx).Where("clothes_uuid = ?", clothesUUID).First(&clothes).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &clothes, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesListByIDs 按主键 ID 批量查询衣物(穿搭详情聚合用)
|
||||||
|
func (d *ClothesDao) ClothesListByIDs(ctx context.Context, ids []int64) ([]mysqlmodel.Clothes, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ClothesListByIDs")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return []mysqlmodel.Clothes{}, nil
|
||||||
|
}
|
||||||
|
var list []mysqlmodel.Clothes
|
||||||
|
if err := d.db.WithContext(ctx).Where("id IN ?", ids).Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/google/uuid"
|
||||||
|
)
|
||||||
|
|
||||||
|
// genFlowUUID 生成流水 UUID(人工调整流水 order_no 兜底用)。
|
||||||
|
func genFlowUUID() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoodsDao 商品数据访问
|
||||||
|
type GoodsDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoodsDao 创建商品 DAO
|
||||||
|
func NewGoodsDao() *GoodsDao {
|
||||||
|
return &GoodsDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsListParams 商品分页列表筛选参数。
|
||||||
|
type GoodsListParams struct {
|
||||||
|
GoodsType int8 // 商品类型筛选,0 表示全部
|
||||||
|
Status int8 // 状态筛选,0 表示全部(含下架)
|
||||||
|
Keyword string // 模糊匹配商品名称/编码
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsList 分页查询商品(管理端含下架商品)
|
||||||
|
func (d *GoodsDao) GoodsList(ctx context.Context, p GoodsListParams) ([]mysqlmodel.Goods, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.Goods
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{})
|
||||||
|
if p.GoodsType != 0 {
|
||||||
|
q = q.Where("goods_type = ?", p.GoodsType)
|
||||||
|
}
|
||||||
|
if p.Status != 0 {
|
||||||
|
q = q.Where("status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if p.Keyword != "" {
|
||||||
|
kw := "%" + p.Keyword + "%"
|
||||||
|
q = q.Where("goods_name LIKE ? OR goods_code LIKE ?", kw, kw)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsGetByID 按主键 ID 查询商品
|
||||||
|
func (d *GoodsDao) GoodsGetByID(ctx context.Context, id int64) (*mysqlmodel.Goods, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var goods mysqlmodel.Goods
|
||||||
|
if err := d.db.WithContext(ctx).First(&goods, id).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &goods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsGetByCode 按商品编码查询商品(含软删,用于编码唯一性校验)
|
||||||
|
func (d *GoodsDao) GoodsGetByCode(ctx context.Context, code string) (*mysqlmodel.Goods, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsGetByCode")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var goods mysqlmodel.Goods
|
||||||
|
if err := d.db.WithContext(ctx).Unscoped().Where("goods_code = ?", code).First(&goods).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &goods, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsCreate 创建商品
|
||||||
|
func (d *GoodsDao) GoodsCreate(ctx context.Context, goods *mysqlmodel.Goods) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsCreate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Create(goods).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsUpdate 按主键更新商品字段
|
||||||
|
func (d *GoodsDao) GoodsUpdate(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsUpdate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsSoftDelete 软删除商品
|
||||||
|
func (d *GoodsDao) GoodsSoftDelete(ctx context.Context, id int64) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsSoftDelete")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Where("id = ?", id).Delete(&mysqlmodel.Goods{}).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationDao 通知数据访问
|
||||||
|
type NotificationDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotificationDao 创建通知 DAO
|
||||||
|
func NewNotificationDao() *NotificationDao {
|
||||||
|
return &NotificationDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NotificationBatchCreate 批量插入通知(单用户/分片广播共用)
|
||||||
|
func (d *NotificationDao) NotificationBatchCreate(ctx context.Context, notifications []mysqlmodel.UserNotification) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.NotificationBatchCreate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if len(notifications) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).CreateInBatches(notifications, 500).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderDao 订单数据访问
|
||||||
|
type OrderDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderDao 创建订单 DAO
|
||||||
|
func NewOrderDao() *OrderDao {
|
||||||
|
return &OrderDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderListParams 订单分页列表筛选参数。
|
||||||
|
type OrderListParams struct {
|
||||||
|
OrderNo string // 订单号(精确)
|
||||||
|
UserID int64 // 用户 ID,0 表示全部
|
||||||
|
Status int8 // 订单状态,0 表示全部
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderList 分页查询订单
|
||||||
|
func (d *OrderDao) OrderList(ctx context.Context, p OrderListParams) ([]mysqlmodel.Order, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.Order
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.Order{})
|
||||||
|
if p.OrderNo != "" {
|
||||||
|
q = q.Where("order_no = ?", p.OrderNo)
|
||||||
|
}
|
||||||
|
if p.UserID != 0 {
|
||||||
|
q = q.Where("user_id = ?", p.UserID)
|
||||||
|
}
|
||||||
|
if p.Status != 0 {
|
||||||
|
q = q.Where("order_status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderGetByOrderNo 按订单号查询订单
|
||||||
|
func (d *OrderDao) OrderGetByOrderNo(ctx context.Context, orderNo string) (*mysqlmodel.Order, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByOrderNo")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var order mysqlmodel.Order
|
||||||
|
if err := d.db.WithContext(ctx).Where("order_no = ?", orderNo).First(&order).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderGetByID 按主键 ID 查询订单
|
||||||
|
func (d *OrderDao) OrderGetByID(ctx context.Context, id int64) (*mysqlmodel.Order, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderGetByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var order mysqlmodel.Order
|
||||||
|
if err := d.db.WithContext(ctx).First(&order, id).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &order, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OutfitDao 穿搭数据访问
|
||||||
|
type OutfitDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOutfitDao 创建穿搭 DAO
|
||||||
|
func NewOutfitDao() *OutfitDao {
|
||||||
|
return &OutfitDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitListParams 穿搭分页列表筛选参数。
|
||||||
|
type OutfitListParams struct {
|
||||||
|
UserID int64 // 用户 ID,0 表示全部
|
||||||
|
IsPublic int8 // 是否公开,0 表示全部
|
||||||
|
AuditStatus int8 // 审核状态,0 表示全部
|
||||||
|
Status int8 // 穿搭状态,0 表示全部
|
||||||
|
Keyword string // 模糊匹配穿搭名称
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitList 分页查询穿搭(管理端全量视角)
|
||||||
|
func (d *OutfitDao) OutfitList(ctx context.Context, p OutfitListParams) ([]mysqlmodel.Outfit, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.Outfit
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{})
|
||||||
|
if p.UserID != 0 {
|
||||||
|
q = q.Where("user_id = ?", p.UserID)
|
||||||
|
}
|
||||||
|
if p.IsPublic != 0 {
|
||||||
|
q = q.Where("is_public = ?", p.IsPublic)
|
||||||
|
}
|
||||||
|
if p.AuditStatus != 0 {
|
||||||
|
q = q.Where("audit_status = ?", p.AuditStatus)
|
||||||
|
}
|
||||||
|
if p.Status != 0 {
|
||||||
|
q = q.Where("status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if p.Keyword != "" {
|
||||||
|
q = q.Where("outfit_name LIKE ?", "%"+p.Keyword+"%")
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitGetByUUID 按穿搭 UUID 查询
|
||||||
|
func (d *OutfitDao) OutfitGetByUUID(ctx context.Context, outfitUUID string) (*mysqlmodel.Outfit, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitGetByUUID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var outfit mysqlmodel.Outfit
|
||||||
|
if err := d.db.WithContext(ctx).Where("outfit_uuid = ?", outfitUUID).First(&outfit).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &outfit, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitItemListByOutfitID 查询穿搭衣物明细
|
||||||
|
func (d *OutfitDao) OutfitItemListByOutfitID(ctx context.Context, outfitID int64) ([]mysqlmodel.OutfitItem, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitItemListByOutfitID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var items []mysqlmodel.OutfitItem
|
||||||
|
if err := d.db.WithContext(ctx).Where("outfit_id = ?", outfitID).Find(&items).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitUpdateByID 按主键更新穿搭字段
|
||||||
|
func (d *OutfitDao) OutfitUpdateByID(ctx context.Context, id int64, updates map[string]any) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitUpdateByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).Where("id = ?", id).Updates(updates).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitAuditWithLog 事务内更新穿搭审核状态并写审核日志。
|
||||||
|
func (d *OutfitDao) OutfitAuditWithLog(ctx context.Context, outfitID int64, auditResult int8, rejectReason string, log *mysqlmodel.OutfitAuditLog) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OutfitAuditWithLog")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&mysqlmodel.Outfit{}).
|
||||||
|
Where("id = ?", outfitID).
|
||||||
|
UpdateColumn("audit_status", auditResult).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
log.TargetID = outfitID
|
||||||
|
log.AuditResult = auditResult
|
||||||
|
log.RejectReason = rejectReason
|
||||||
|
return tx.Create(log).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// QuotaGrantDao 额度凭证数据访问(有有效期赠送额度)。
|
||||||
|
type QuotaGrantDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewQuotaGrantDao 创建额度凭证 DAO。
|
||||||
|
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")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.QuotaGrant
|
||||||
|
if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return list, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaGrantGetByID 按主键查询凭证(未删除)。
|
||||||
|
func (d *QuotaGrantDao) QuotaGrantGetByID(ctx context.Context, id int64) (*mysqlmodel.QuotaGrant, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaGrantGetByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var grant mysqlmodel.QuotaGrant
|
||||||
|
if err := d.db.WithContext(ctx).First(&grant, id).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var quota mysqlmodel.Quota
|
||||||
|
if err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND quota_type = ?", userID, quotaType).
|
||||||
|
First("a).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return "a, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDailyUsageGetByUserIDAndDate 查询用户某日使用计数(当日额度已用,Redis 异常时 MySQL 兜底)。
|
||||||
|
func (d *QuotaGrantDao) UserDailyUsageGetByUserIDAndDate(ctx context.Context, userID, statDate int64) (*mysqlmodel.UserDailyUsage, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserDailyUsageGetByUserIDAndDate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var usage mysqlmodel.UserDailyUsage
|
||||||
|
if err := d.db.WithContext(ctx).
|
||||||
|
Where("user_id = ? AND stat_date = ?", userID, statDate).
|
||||||
|
First(&usage).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &usage, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RefundDao 退款单数据访问
|
||||||
|
type RefundDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRefundDao 创建退款 DAO
|
||||||
|
func NewRefundDao() *RefundDao {
|
||||||
|
return &RefundDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundListParams 退款单分页列表筛选参数。
|
||||||
|
type RefundListParams struct {
|
||||||
|
Status int8 // 退款状态(WithStatus 为 true 时生效)
|
||||||
|
WithStatus bool // 是否按状态筛选(退款状态 0=待处理 是合法值,需显式区分「不过滤」)
|
||||||
|
OrderNo string // 订单号(精确)
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundList 分页查询退款单
|
||||||
|
func (d *RefundDao) RefundList(ctx context.Context, p RefundListParams) ([]mysqlmodel.RefundFlowLog, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.RefundFlowLog
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{})
|
||||||
|
if p.WithStatus {
|
||||||
|
q = q.Where("status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if p.OrderNo != "" {
|
||||||
|
q = q.Where("order_no = ?", p.OrderNo)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundGetByRefundNo 按退款单号查询
|
||||||
|
func (d *RefundDao) RefundGetByRefundNo(ctx context.Context, refundNo string) (*mysqlmodel.RefundFlowLog, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundGetByRefundNo")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var refund mysqlmodel.RefundFlowLog
|
||||||
|
if err := d.db.WithContext(ctx).Where("refund_no = ?", refundNo).First(&refund).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &refund, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundTransition 条件更新退款状态(仅 from 状态可流转到 to,行锁防并发重复审核)。
|
||||||
|
func (d *RefundDao) RefundTransition(ctx context.Context, refundNo string, from, to int8, failReason string) (bool, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.RefundTransition")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
updates := map[string]any{"status": to}
|
||||||
|
if failReason != "" {
|
||||||
|
updates["fail_reason"] = failReason
|
||||||
|
}
|
||||||
|
res := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
|
||||||
|
Where("refund_no = ? AND status = ?", refundNo, from).
|
||||||
|
Updates(updates)
|
||||||
|
if res.Error != nil {
|
||||||
|
span.RecordError(res.Error)
|
||||||
|
return false, res.Error
|
||||||
|
}
|
||||||
|
return res.RowsAffected > 0, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,518 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StatsDao 仪表盘聚合统计数据访问
|
||||||
|
type StatsDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatsDao 创建统计 DAO
|
||||||
|
func NewStatsDao() *StatsDao {
|
||||||
|
return &StatsDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStats 用户统计。
|
||||||
|
type UserStats struct {
|
||||||
|
Total int64 // 用户总数
|
||||||
|
TodayNew int64 // 今日新增
|
||||||
|
Normal int64 // 正常
|
||||||
|
TempBan int64 // 临时禁用
|
||||||
|
PermBan int64 // 永久禁用
|
||||||
|
Deactivated int64 // 已注销
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderStats 订单统计。
|
||||||
|
type OrderStats struct {
|
||||||
|
Total int64 // 订单总数
|
||||||
|
Pending int64 // 待支付
|
||||||
|
Paid int64 // 已支付
|
||||||
|
Cancelled int64 // 已取消
|
||||||
|
Expired int64 // 已过期
|
||||||
|
Refunded int64 // 已退款
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevenueStats 营收统计(单位分)。
|
||||||
|
type RevenueStats struct {
|
||||||
|
TotalRevenue int64 // 累计营收(已支付+已退款订单实付金额)
|
||||||
|
TodayRevenue int64 // 今日营收(今日支付订单实付金额)
|
||||||
|
RefundTotal int64 // 累计退款金额
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoStats 待办统计。
|
||||||
|
type TodoStats struct {
|
||||||
|
RefundPending int64 // 待处理退款数
|
||||||
|
OutfitPendingAudit int64 // 待审核穿搭数
|
||||||
|
GoodsOnShelf int64 // 上架商品数
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStatsGet 聚合用户总数与今日新增
|
||||||
|
func (d *StatsDao) UserStatsGet(ctx context.Context, todayStart int64) (*UserStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &UserStats{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Count(&s.Total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("created_at >= ?", todayStart).Count(&s.TodayNew).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserStatsGetByStatus 按状态统计用户数(单次 group by)
|
||||||
|
func (d *StatsDao) UserStatsGetByStatus(ctx context.Context) (map[int8]int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserStatsGetByStatus")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
Status int8
|
||||||
|
Cnt int64
|
||||||
|
}{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Select("status, COUNT(*) AS cnt").
|
||||||
|
Group("status").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[int8]int64, len(rows))
|
||||||
|
for _, r := range rows {
|
||||||
|
m[r.Status] = r.Cnt
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OrderStatsGet 聚合订单状态统计(单次 group by)
|
||||||
|
func (d *StatsDao) OrderStatsGet(ctx context.Context) (*OrderStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.OrderStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
rows := []struct {
|
||||||
|
OrderStatus int8
|
||||||
|
Cnt int64
|
||||||
|
}{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||||
|
Select("order_status, COUNT(*) AS cnt").
|
||||||
|
Group("order_status").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s := &OrderStats{}
|
||||||
|
for _, r := range rows {
|
||||||
|
s.Total += r.Cnt
|
||||||
|
switch r.OrderStatus {
|
||||||
|
case 1:
|
||||||
|
s.Pending = r.Cnt
|
||||||
|
case 2:
|
||||||
|
s.Paid = r.Cnt
|
||||||
|
case 3:
|
||||||
|
s.Cancelled = r.Cnt
|
||||||
|
case 4:
|
||||||
|
s.Expired = r.Cnt
|
||||||
|
case 5:
|
||||||
|
s.Refunded = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RevenueStatsGet 聚合营收统计(已支付与已退款订单均计营收;今日按支付时间统计)
|
||||||
|
func (d *StatsDao) RevenueStatsGet(ctx context.Context, todayStart int64) (*RevenueStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.RevenueStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &RevenueStats{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||||
|
Where("order_status IN ?", []int8{2, 5}).
|
||||||
|
Select("COALESCE(SUM(pay_amount), 0)").
|
||||||
|
Scan(&s.TotalRevenue).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||||
|
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, todayStart).
|
||||||
|
Select("COALESCE(SUM(pay_amount), 0)").
|
||||||
|
Scan(&s.TodayRevenue).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||||
|
Where("order_status = ?", int8(5)).
|
||||||
|
Select("COALESCE(SUM(refund_amount), 0)").
|
||||||
|
Scan(&s.RefundTotal).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TodoStatsGet 聚合待办统计
|
||||||
|
func (d *StatsDao) TodoStatsGet(ctx context.Context) (*TodoStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.TodoStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &TodoStats{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.RefundFlowLog{}).
|
||||||
|
Where("status = ?", int8(0)).
|
||||||
|
Count(&s.RefundPending).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.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 {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// countByInt8 通用分组计数行(小整型枚举 + 计数)。
|
||||||
|
type countByInt8 struct {
|
||||||
|
K int8
|
||||||
|
Cnt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateCount 按日分组计数行。
|
||||||
|
type dateCount struct {
|
||||||
|
D string
|
||||||
|
Cnt int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// dateRevenue 按日分组营收行。
|
||||||
|
type dateRevenue struct {
|
||||||
|
D string
|
||||||
|
Cnt int64
|
||||||
|
Rev int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentStats 内容审核统计。
|
||||||
|
type ContentStats struct {
|
||||||
|
OutfitTotal int64
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPStats 会员统计。
|
||||||
|
type VIPStats struct {
|
||||||
|
MemberTotal int64
|
||||||
|
Monthly int64
|
||||||
|
Yearly int64
|
||||||
|
Trial int64
|
||||||
|
ExpiringSoon int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsTypeCount 商品类型数量。
|
||||||
|
type GoodsTypeCount struct {
|
||||||
|
Type int8
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsStats 商品统计。
|
||||||
|
type GoodsStats struct {
|
||||||
|
Total int64
|
||||||
|
OnShelf int64
|
||||||
|
OffShelf int64
|
||||||
|
ByType []GoodsTypeCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendStats 趋势原始聚合(按 YYYY-MM-DD 分组)。
|
||||||
|
type TrendStats struct {
|
||||||
|
NewUsers map[string]int64 // 新增用户
|
||||||
|
Orders map[string]int64 // 已支付订单量
|
||||||
|
Revenue map[string]int64 // 营收(分)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ContentStatsGet 聚合内容审核概况:穿搭/衣物/衣柜分享。
|
||||||
|
func (d *StatsDao) ContentStatsGet(ctx context.Context) (*ContentStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.ContentStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &ContentStats{}
|
||||||
|
|
||||||
|
// 穿搭:audit_status 分布 + 封禁数
|
||||||
|
outfitRows := []countByInt8{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Outfit{}).
|
||||||
|
Select("audit_status AS k, COUNT(*) AS cnt").
|
||||||
|
Group("audit_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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
|
||||||
|
Select("image_process_status AS k, COUNT(*) AS cnt").
|
||||||
|
Group("image_process_status").
|
||||||
|
Scan(&clothesRows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range clothesRows {
|
||||||
|
s.ClothesTotal += r.Cnt
|
||||||
|
switch r.K {
|
||||||
|
case 1, 2, 3:
|
||||||
|
s.ClothesProcessing += r.Cnt
|
||||||
|
case 4:
|
||||||
|
s.ClothesSuccess = r.Cnt
|
||||||
|
case 5:
|
||||||
|
s.ClothesFailed = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 衣柜分享:status 分布
|
||||||
|
shareRows := []countByInt8{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.ClosetShare{}).
|
||||||
|
Select("status AS k, COUNT(*) AS cnt").
|
||||||
|
Group("status").
|
||||||
|
Scan(&shareRows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range shareRows {
|
||||||
|
s.ShareTotal += r.Cnt
|
||||||
|
switch r.K {
|
||||||
|
case 1:
|
||||||
|
s.ShareNormal = r.Cnt
|
||||||
|
case 2:
|
||||||
|
s.ShareCancelled = r.Cnt
|
||||||
|
case 3:
|
||||||
|
s.ShareExpired = r.Cnt
|
||||||
|
case 4:
|
||||||
|
s.ShareBanned = r.Cnt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPStatsGet 聚合会员概况(有效会员 + 即将到期)。
|
||||||
|
func (d *StatsDao) VIPStatsGet(ctx context.Context, now, soonThreshold int64) (*VIPStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &VIPStats{}
|
||||||
|
activeVIP := []int8{2, 3, 4}
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("vip_type IN ? AND vip_expire > ?", activeVIP, now).
|
||||||
|
Count(&s.MemberTotal).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("vip_type = ? AND vip_expire > ?", int8(2), now).
|
||||||
|
Count(&s.Monthly).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("vip_type = ? AND vip_expire > ?", int8(3), now).
|
||||||
|
Count(&s.Yearly).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("vip_type = ? AND vip_expire > ?", int8(4), now).
|
||||||
|
Count(&s.Trial).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("vip_type IN ? AND vip_expire >= ? AND vip_expire <= ?", activeVIP, now, soonThreshold).
|
||||||
|
Count(&s.ExpiringSoon).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GoodsStatsGet 聚合商品统计(上架/下架 + 类型分布)。
|
||||||
|
func (d *StatsDao) GoodsStatsGet(ctx context.Context) (*GoodsStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.GoodsStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &GoodsStats{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).Count(&s.Total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
|
||||||
|
Where("status = ?", int8(1)).
|
||||||
|
Count(&s.OnShelf).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
|
||||||
|
Where("status = ?", int8(2)).
|
||||||
|
Count(&s.OffShelf).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows := []countByInt8{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Goods{}).
|
||||||
|
Select("goods_type AS k, COUNT(*) AS cnt").
|
||||||
|
Group("goods_type").
|
||||||
|
Scan(&rows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
s.ByType = append(s.ByType, GoodsTypeCount{Type: r.K, Count: r.Cnt})
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TrendStatsGet 聚合近 N 天趋势(新增用户/已支付订单量/营收,按天分组)。
|
||||||
|
func (d *StatsDao) TrendStatsGet(ctx context.Context, startTs int64) (*TrendStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.TrendStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &TrendStats{
|
||||||
|
NewUsers: map[string]int64{},
|
||||||
|
Orders: map[string]int64{},
|
||||||
|
Revenue: map[string]int64{},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 新增用户:按创建时间
|
||||||
|
userRows := []dateCount{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("created_at >= ?", startTs).
|
||||||
|
Select("DATE_FORMAT(FROM_UNIXTIME(created_at), '%Y-%m-%d') AS d, COUNT(*) AS cnt").
|
||||||
|
Group("d").
|
||||||
|
Scan(&userRows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range userRows {
|
||||||
|
s.NewUsers[r.D] = r.Cnt
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已支付/已退款订单量 + 营收:按支付时间
|
||||||
|
orderRows := []dateRevenue{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Order{}).
|
||||||
|
Where("order_status IN ? AND pay_time >= ?", []int8{2, 5}, startTs).
|
||||||
|
Select("DATE_FORMAT(FROM_UNIXTIME(pay_time), '%Y-%m-%d') AS d, COUNT(*) AS cnt, COALESCE(SUM(pay_amount), 0) AS rev").
|
||||||
|
Group("d").
|
||||||
|
Scan(&orderRows).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range orderRows {
|
||||||
|
s.Orders[r.D] = r.Cnt
|
||||||
|
s.Revenue[r.D] = r.Rev
|
||||||
|
}
|
||||||
|
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageStats 今日消耗统计。
|
||||||
|
type UsageStats struct {
|
||||||
|
AIRecommend int64 // 今日AI推荐消耗次数
|
||||||
|
ClosetShare int64 // 今日衣柜分享消耗次数
|
||||||
|
OutfitShare int64 // 今日社区分享消耗次数
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocialStats 内容互动统计。
|
||||||
|
type SocialStats struct {
|
||||||
|
CosImageCount int64 // COS总图数
|
||||||
|
LikeCount int64 // 点赞数
|
||||||
|
CollectCount int64 // 收藏数
|
||||||
|
}
|
||||||
|
|
||||||
|
// UsageStatsGet 聚合今日消耗次数(AI推荐/衣柜分享/社区分享,按 stat_date 单次求和)。
|
||||||
|
func (d *StatsDao) UsageStatsGet(ctx context.Context, statDate int64) (*UsageStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UsageStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &UsageStats{}
|
||||||
|
row := struct {
|
||||||
|
AIRecommend int64
|
||||||
|
ClosetShare int64
|
||||||
|
OutfitShare int64
|
||||||
|
}{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.UserDailyUsage{}).
|
||||||
|
Where("stat_date = ?", statDate).
|
||||||
|
Select("COALESCE(SUM(ai_recommend_count), 0) AS ai_recommend, COALESCE(SUM(closet_share_count), 0) AS closet_share, COALESCE(SUM(outfit_share_count), 0) AS outfit_share").
|
||||||
|
Scan(&row).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
s.AIRecommend = row.AIRecommend
|
||||||
|
s.ClosetShare = row.ClosetShare
|
||||||
|
s.OutfitShare = row.OutfitShare
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SocialStatsGet 聚合内容互动统计(COS 总图数/点赞数/收藏数)。
|
||||||
|
func (d *StatsDao) SocialStatsGet(ctx context.Context) (*SocialStats, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.SocialStatsGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
s := &SocialStats{}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Clothes{}).
|
||||||
|
Where("processed_image <> ''").
|
||||||
|
Count(&s.CosImageCount).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Like{}).
|
||||||
|
Where("status = ?", int8(1)).
|
||||||
|
Count(&s.LikeCount).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.Collect{}).
|
||||||
|
Where("status = ?", int8(1)).
|
||||||
|
Count(&s.CollectCount).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return s, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
package mysqldao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserDao 用户数据访问
|
||||||
|
type UserDao struct {
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserDao 创建用户 DAO
|
||||||
|
func NewUserDao() *UserDao {
|
||||||
|
return &UserDao{db: global.MysqlEngine}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserListParams 用户分页列表筛选参数。
|
||||||
|
type UserListParams struct {
|
||||||
|
Keyword string // 模糊匹配昵称/手机号/OpenID(任一命中)
|
||||||
|
Status int8 // 状态筛选,0 表示全部
|
||||||
|
Page int
|
||||||
|
Size int
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserList 分页查询用户(关键词 + 状态筛选)
|
||||||
|
func (d *UserDao) UserList(ctx context.Context, p UserListParams) ([]mysqlmodel.User, int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var list []mysqlmodel.User
|
||||||
|
var total int64
|
||||||
|
q := d.db.WithContext(ctx).Model(&mysqlmodel.User{})
|
||||||
|
if p.Keyword != "" {
|
||||||
|
kw := "%" + p.Keyword + "%"
|
||||||
|
q = q.Where("username LIKE ? OR phone LIKE ? OR wx_open_id LIKE ?", kw, kw, kw)
|
||||||
|
}
|
||||||
|
if p.Status != 0 {
|
||||||
|
q = q.Where("status = ?", p.Status)
|
||||||
|
}
|
||||||
|
if err := q.Count(&total).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
if err := q.Offset((p.Page - 1) * p.Size).Limit(p.Size).Order("id DESC").Find(&list).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
return list, total, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGetByID 按主键 ID 查询用户
|
||||||
|
func (d *UserDao) UserGetByID(ctx context.Context, userID int64) (*mysqlmodel.User, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var user mysqlmodel.User
|
||||||
|
if err := d.db.WithContext(ctx).First(&user, userID).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserGetByUUID 按 UUID 查询用户
|
||||||
|
func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserGetByUUID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var user mysqlmodel.User
|
||||||
|
if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &user, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserUpdateByID 按主键 ID 更新用户字段
|
||||||
|
func (d *UserDao) UserUpdateByID(ctx context.Context, userID int64, updates map[string]any) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserUpdateByID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", userID).Updates(updates).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaListByUserID 查询用户的所有配额
|
||||||
|
func (d *UserDao) QuotaListByUserID(ctx context.Context, userID int64) ([]mysqlmodel.Quota, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.QuotaListByUserID")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var quotas []mysqlmodel.Quota
|
||||||
|
if err := d.db.WithContext(ctx).Where("user_id = ?", userID).Find("as).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return quotas, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPAdjustWithFlow 事务内人工调整用户 VIP 并写流水。
|
||||||
|
func (d *UserDao) VIPAdjustWithFlow(ctx context.Context, user *mysqlmodel.User, flow *mysqlmodel.VIPFlowLog) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.VIPAdjustWithFlow")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
return d.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Model(&mysqlmodel.User{}).
|
||||||
|
Where("id = ?", user.ID).
|
||||||
|
Updates(map[string]any{
|
||||||
|
"vip_type": user.VIPType,
|
||||||
|
"vip_start": user.VIPStart,
|
||||||
|
"vip_expire": user.VIPExpire,
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 人工调整无订单号,用流水 UUID 填充 order_no 满足联合唯一索引。
|
||||||
|
if flow.OrderNo == "" {
|
||||||
|
flow.OrderNo = genFlowUUID()
|
||||||
|
}
|
||||||
|
return tx.Create(flow).Error
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserIDListAll 分批遍历全部用户 ID(通知广播用)。
|
||||||
|
func (d *UserDao) UserIDListAll(ctx context.Context, lastID int64, limit int) ([]int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "mysqldao.UserIDListAll")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
var ids []int64
|
||||||
|
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).
|
||||||
|
Where("id > ?", lastID).
|
||||||
|
Order("id ASC").
|
||||||
|
Limit(limit).
|
||||||
|
Pluck("id", &ids).Error; err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return ids, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package mysqlmodel
|
||||||
|
|
||||||
|
// AdminUser 管理员账号(管理后台自建表,唯一不共享业务服务的表)。
|
||||||
|
// 字符串字段显式指定 type:varchar 长度:MySQL 无法对 TEXT/BLOB 列建普通唯一索引,
|
||||||
|
// 且 varchar 长度受 utf8mb4 索引 3072 字节上限约束。
|
||||||
|
type AdminUser struct {
|
||||||
|
Model
|
||||||
|
Username string `gorm:"column:username;type:varchar(32);uniqueIndex:uk_admin_username"` // 登录账号
|
||||||
|
PasswordHash string `gorm:"column:password_hash;type:varchar(100)"` // bcrypt 密码哈希(60 字符)
|
||||||
|
Nickname string `gorm:"column:nickname;type:varchar(32)"` // 显示名
|
||||||
|
Role int8 `gorm:"column:role"` // 角色 1=超级管理员 2=普通管理员
|
||||||
|
Status int8 `gorm:"column:status"` // 状态 1=启用 2=禁用
|
||||||
|
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
|
||||||
|
}
|
||||||
|
|
||||||
|
func (AdminUser) TableName() string {
|
||||||
|
return "admin_users"
|
||||||
|
}
|
||||||
@@ -0,0 +1,338 @@
|
|||||||
|
package mysqlmodel
|
||||||
|
|
||||||
|
// 本文件为业务服务(zt-closet-backend)数据表模型的镜像定义。
|
||||||
|
// 管理后台与业务服务共用同一 MySQL 库,直接读写业务表;
|
||||||
|
// 字段与表名必须与业务服务 internal/mysqlmodel 保持一致。
|
||||||
|
|
||||||
|
// ===== 用户 =====
|
||||||
|
|
||||||
|
type User struct {
|
||||||
|
Model
|
||||||
|
UserUUID string `gorm:"column:user_uuid"` // 用户UUID
|
||||||
|
WxOpenID string `gorm:"column:wx_open_id;uniqueIndex:uk_user_wx_open_id"` // 微信openid
|
||||||
|
Username string `gorm:"column:username"` // 用户名
|
||||||
|
Avatar string `gorm:"column:avatar"` // 头像存储位置
|
||||||
|
Sex int8 `gorm:"column:sex"` // 性别 1=男 2=女
|
||||||
|
BirthDay int64 `gorm:"column:birth_day"` // 生日
|
||||||
|
Phone string `gorm:"column:phone"` // 手机号
|
||||||
|
IsNewUser bool `gorm:"column:is_new_user"` // 是否新用户
|
||||||
|
Status int8 `gorm:"column:status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||||
|
BanReason string `gorm:"column:ban_reason"` // 封禁原因
|
||||||
|
BanStart int64 `gorm:"column:ban_start"` // 封禁开始时间
|
||||||
|
BanExpire int64 `gorm:"column:ban_expire"` // 封禁到期时间
|
||||||
|
VIPType int8 `gorm:"column:vip_type"` // VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||||
|
VIPStart int64 `gorm:"column:vip_start"` // VIP开始时间
|
||||||
|
VIPExpire int64 `gorm:"column:vip_expire"` // VIP到期时间
|
||||||
|
LastLoginTime int64 `gorm:"column:last_login_time"` // 最近登录时间
|
||||||
|
}
|
||||||
|
|
||||||
|
func (User) TableName() string {
|
||||||
|
return "users"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 商品 =====
|
||||||
|
|
||||||
|
type Goods struct {
|
||||||
|
Model
|
||||||
|
GoodsCode string `gorm:"column:goods_code"` // 商品编码(唯一)
|
||||||
|
GoodsName string `gorm:"column:goods_name"` // 商品名称
|
||||||
|
GoodsType int8 `gorm:"column:goods_type"` // 商品类型 1=会员包 2=上传包 3=AI推荐包 4=衣柜分享包 5=搭配分享包
|
||||||
|
Price int64 `gorm:"column:price"` // 商品原价(分)
|
||||||
|
DiscountPrice int64 `gorm:"column:discount_price"` // 商品售价(分)
|
||||||
|
MemberDays int64 `gorm:"column:member_days"` // 赠送会员天数
|
||||||
|
CosQuota int64 `gorm:"column:cos_quota"` // 赠送总上传额度
|
||||||
|
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度
|
||||||
|
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度
|
||||||
|
OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度
|
||||||
|
ValidDays int64 `gorm:"column:valid_days"` // 赠送额度有效期天数(加量包用;会员包为 0 表示跟随 VIP)
|
||||||
|
Description string `gorm:"column:description"` // 商品描述
|
||||||
|
Status int8 `gorm:"column:status"` // 状态 1=上架 2=下架
|
||||||
|
ProductID string `gorm:"column:product_id"` // 虚拟支付道具ID(空表示未发布)
|
||||||
|
ProductStatus int8 `gorm:"column:product_status"` // 虚拟支付道具发布状态 0=未发布 1=发布中 2=已发布 3=发布失败
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Goods) TableName() string {
|
||||||
|
return "goods"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 订单 =====
|
||||||
|
|
||||||
|
type Order struct {
|
||||||
|
Model
|
||||||
|
// OrderNo 商户订单号;唯一索引保证订单号全局唯一。
|
||||||
|
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_order_no"`
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 下单用户ID
|
||||||
|
GoodsID int64 `gorm:"column:goods_id"` // 关联商品ID
|
||||||
|
GoodsName string `gorm:"column:goods_name"` // 商品名称快照
|
||||||
|
UnitPrice int64 `gorm:"column:unit_price"` // 商品单价(分)
|
||||||
|
TotalAmount int64 `gorm:"column:total_amount"` // 订单总金额(分)
|
||||||
|
PayAmount int64 `gorm:"column:pay_amount"` // 实付金额(分)
|
||||||
|
PayChannel int8 `gorm:"column:pay_channel"` // 支付渠道 1=微信支付 2=微信虚拟支付(XPay)
|
||||||
|
OrderStatus int8 `gorm:"column:order_status"` // 订单状态 1=待支付 2=已支付 3=已取消 4=已过期 5=已退款
|
||||||
|
TransactionID string `gorm:"column:transaction_id"` // 微信支付流水号
|
||||||
|
MemberDays int64 `gorm:"column:member_days"` // 购买的会员天数(快照)
|
||||||
|
CosQuota int64 `gorm:"column:cos_quota"` // 赠送上传额度(快照)
|
||||||
|
AIRecommendQuota int64 `gorm:"column:ai_recommend_quota"` // 赠送AI推荐额度(快照)
|
||||||
|
ClosetShareQuota int64 `gorm:"column:closet_share_quota"` // 赠送衣柜分享额度(快照)
|
||||||
|
OutfitShareQuota int64 `gorm:"column:outfit_share_quota"` // 赠送搭配分享额度(快照)
|
||||||
|
ExpireTime int64 `gorm:"column:expire_time"` // 支付过期时间
|
||||||
|
PayTime int64 `gorm:"column:pay_time"` // 支付完成时间
|
||||||
|
RefundTime int64 `gorm:"column:refund_time"` // 退款时间
|
||||||
|
RefundAmount int64 `gorm:"column:refund_amount"` // 退款金额(分)
|
||||||
|
Remark string `gorm:"column:remark"` // 订单备注
|
||||||
|
CallbackRaw string `gorm:"column:callback_raw"` // 支付回调原始报文
|
||||||
|
XPayProductID string `gorm:"column:xpay_product_id"` // 虚拟支付道具ID(快照,pay_channel=2 时使用)
|
||||||
|
XPayPlatform string `gorm:"column:xpay_platform"` // 虚拟支付平台 android/ios/harmony/windows
|
||||||
|
XPayEnv int8 `gorm:"column:xpay_env"` // 虚拟支付环境 0=现网 1=沙箱
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Order) TableName() string {
|
||||||
|
return "orders"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 退款 =====
|
||||||
|
|
||||||
|
type RefundFlowLog struct {
|
||||||
|
Model
|
||||||
|
OrderID int64 `gorm:"column:order_id"` // 关联订单ID
|
||||||
|
OrderNo string `gorm:"column:order_no"` // 商户订单号(冗余,方便查询)
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 用户ID(冗余,方便按用户查)
|
||||||
|
// RefundNo 商户退款单号;唯一索引为退款申请防重/重试幂等依据。
|
||||||
|
RefundNo string `gorm:"column:refund_no;uniqueIndex:uk_refund_flow_no"`
|
||||||
|
WechatRefundNo string `gorm:"column:wechat_refund_no"` // 微信退款单号
|
||||||
|
RefundAmount int64 `gorm:"column:refund_amount"` // 本次退款金额(分)
|
||||||
|
RefundReason string `gorm:"column:refund_reason"` // 退款原因
|
||||||
|
Status int8 `gorm:"column:status"` // 退款状态 0=待处理 1=退款中 2=退款成功 3=退款失败
|
||||||
|
ApplyTime int64 `gorm:"column:apply_time"` // 申请时间
|
||||||
|
SuccessTime int64 `gorm:"column:success_time"` // 退款成功时间
|
||||||
|
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||||
|
CallbackRaw string `gorm:"column:callback_raw"` // 微信退款回调原始报文
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RefundFlowLog) TableName() string {
|
||||||
|
return "refund_flow_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 穿搭 =====
|
||||||
|
|
||||||
|
type Outfit struct {
|
||||||
|
Model
|
||||||
|
OutfitUUID string `gorm:"column:outfit_uuid"` // 穿搭UUID
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 所属用户ID
|
||||||
|
OutfitName string `gorm:"column:outfit_name"` // 穿搭名称
|
||||||
|
OutfitDesc string `gorm:"column:outfit_desc"` // 穿搭描述
|
||||||
|
Scene string `gorm:"column:scene"` // 适合场景
|
||||||
|
Source int8 `gorm:"column:source"` // 来源 1=用户创建 2=AI推荐
|
||||||
|
IsPublic int8 `gorm:"column:is_public"` // 是否公开 1=不公开 2=公开
|
||||||
|
AuditStatus int8 `gorm:"column:audit_status"` // 审核状态 1=待审核 2=审核通过 3=审核不通过
|
||||||
|
Status int8 `gorm:"column:status"` // 状态 1=正常 2=封禁 3=待用户保存
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Outfit) TableName() string {
|
||||||
|
return "outfits"
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutfitItem struct {
|
||||||
|
Model
|
||||||
|
OutfitID int64 `gorm:"column:outfit_id"` // 穿搭ID
|
||||||
|
ClothesID int64 `gorm:"column:clothes_id"` // 衣物ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OutfitItem) TableName() string {
|
||||||
|
return "outfit_items"
|
||||||
|
}
|
||||||
|
|
||||||
|
type OutfitAuditLog struct {
|
||||||
|
Model
|
||||||
|
TargetID int64 `gorm:"column:target_id"` // 穿搭ID
|
||||||
|
AuditResult int8 `gorm:"column:audit_result"` // 审核结果 1=通过 2=驳回
|
||||||
|
RejectReason string `gorm:"column:reject_reason"` // 审核原因
|
||||||
|
AuditTime int64 `gorm:"column:audit_time"` // 审核时间
|
||||||
|
}
|
||||||
|
|
||||||
|
func (OutfitAuditLog) TableName() string {
|
||||||
|
return "outfit_audit_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 衣物 =====
|
||||||
|
|
||||||
|
type Clothes struct {
|
||||||
|
Model
|
||||||
|
ClothesUUID string `gorm:"column:clothes_uuid"` // 衣物UUID
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 所属用户ID
|
||||||
|
CategoryMain string `gorm:"column:category_main"` // 衣物大类 上装/下装/连衣裙/外套/鞋/配饰
|
||||||
|
CategorySub string `gorm:"column:category_sub"` // 衣物小类 T恤/牛仔裤/运动鞋/发带/戒指等
|
||||||
|
Style string `gorm:"column:style"` // 风格
|
||||||
|
ColorMain string `gorm:"column:color"` // 主颜色
|
||||||
|
ColorSub string `gorm:"column:color_sub"` // 副颜色
|
||||||
|
Thickness string `gorm:"column:thickness"` // 厚度
|
||||||
|
Version string `gorm:"column:version"` // 版型 宽松/修身/阔腿/紧身/常规合身
|
||||||
|
Material string `gorm:"column:material"` // 材质 牛仔布/针织/皮质/纯棉/羊毛等
|
||||||
|
Pattern string `gorm:"column:pattern"` // 花纹
|
||||||
|
SleeveType string `gorm:"column:sleeve_type"` // 袖型
|
||||||
|
ClothesType string `gorm:"column:clothes_type"` // 衣长类型 短款/常规/中长/长款
|
||||||
|
NecklineType string `gorm:"column:neckline_type"` // 领型
|
||||||
|
Formality string `gorm:"column:formality"` // 正式程度 1-5
|
||||||
|
Occasions string `gorm:"column:occasions"` // 适用场合
|
||||||
|
VisualImpact string `gorm:"column:visual_impact"` // 视觉冲击力 1-5
|
||||||
|
Name string `gorm:"column:name"` // 名称
|
||||||
|
Remark string `gorm:"column:remark"` // 备注
|
||||||
|
ProcessedImage string `gorm:"column:processed_image"` // 处理后图片存储位置 相对路径
|
||||||
|
ImageSize int64 `gorm:"column:image_size"` // 图片大小 单位:kb
|
||||||
|
ImageProcessStatus int8 `gorm:"column:image_process_status"` // 处理状态 1=抠图中 2=分类中 3=排队中 4=成功 5=失败
|
||||||
|
FailReason string `gorm:"column:fail_reason"` // 失败原因
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Clothes) TableName() string {
|
||||||
|
return "clothes"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 衣柜分享 =====
|
||||||
|
|
||||||
|
type ClosetShare struct {
|
||||||
|
Model
|
||||||
|
ShareUUID string `gorm:"column:share_uuid"` // 分享UUID
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 所属用户ID
|
||||||
|
ShareType int8 `gorm:"column:share_type"` // 分享类型 1=部分衣物分享 2=全量快照分享
|
||||||
|
ExpireTime int64 `gorm:"column:expire_time"` // 过期时间
|
||||||
|
Status int8 `gorm:"column:status"` // 分享状态 1=正常 2=用户取消 3=已过期 4=管理员封禁
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ClosetShare) TableName() string {
|
||||||
|
return "closet_shares"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 额度 =====
|
||||||
|
|
||||||
|
type Quota struct {
|
||||||
|
Model
|
||||||
|
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_user_quota,priority:1"` // 用户ID
|
||||||
|
QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_user_quota,priority:2"` // 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||||
|
Total int64 `gorm:"column:total"` // 免费额度底值(仅 Cos 永久额度使用;AI/社区/衣柜 每日额度不再落库)
|
||||||
|
Used int64 `gorm:"column:used"` // 免费额度已用(仅 Cos 使用,永久累计)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Quota) TableName() string {
|
||||||
|
return "user_quotas"
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaGrant 有有效期额度凭证(会员包/加量包/广告激励)。
|
||||||
|
// AI/社区/衣柜 每日额度场景 Amount 表示「有效期内每天额外赠送次数」,Used 恒为 0;
|
||||||
|
// Cos 永久额度场景 Amount/Used 表示「一次性赠送库存及其已用」。
|
||||||
|
type QuotaGrant struct {
|
||||||
|
Model
|
||||||
|
UserID int64 `gorm:"column:user_id;index:idx_grant_user_type_expire,priority:1"` // 用户ID
|
||||||
|
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=广告激励
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QuotaGrant) TableName() string {
|
||||||
|
return "quota_grants"
|
||||||
|
}
|
||||||
|
|
||||||
|
// UserDailyUsage 用户每日使用计数。
|
||||||
|
type UserDailyUsage struct {
|
||||||
|
Model
|
||||||
|
// UserID 用户ID;与 StatDate 组成联合唯一索引,保证每日每用户仅一行。
|
||||||
|
UserID int64 `gorm:"column:user_id;uniqueIndex:uk_daily_usage_user_date,priority:1"`
|
||||||
|
StatDate int64 `gorm:"column:stat_date;uniqueIndex:uk_daily_usage_user_date,priority:2"` // 统计日期(YYYYMMDD)
|
||||||
|
AIRecommendCount int64 `gorm:"column:ai_recommend_count"` // 当日AI推荐次数
|
||||||
|
OutfitShareCount int64 `gorm:"column:outfit_share_count"` // 当日社区分享次数
|
||||||
|
ClosetShareCount int64 `gorm:"column:closet_share_count"` // 当日衣柜分享次数
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserDailyUsage) TableName() string {
|
||||||
|
return "user_daily_usages"
|
||||||
|
}
|
||||||
|
|
||||||
|
type QuotaFlowLog struct {
|
||||||
|
Model
|
||||||
|
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 int8 `gorm:"column:change_type;uniqueIndex:uk_quota_flow_idem,priority:3"`
|
||||||
|
ChangeNum int64 `gorm:"column:change_num"` // 变更数量(正数增加,负数减少)
|
||||||
|
OldValue int64 `gorm:"column:old_value"` // 变更前数值
|
||||||
|
NewValue int64 `gorm:"column:new_value"` // 变更后数值
|
||||||
|
// OrderNo 关联订单号(购买/退款类);人工调整类由 DAO 写入流水 UUID 保证联合唯一索引不冲突。
|
||||||
|
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_quota_flow_idem,priority:1"`
|
||||||
|
QuotaType int8 `gorm:"column:quota_type;uniqueIndex:uk_quota_flow_idem,priority:2"` // 关联业务类型 1=cos 2=AI 3=社区分享 4=衣柜分享
|
||||||
|
RelatedID int64 `gorm:"column:related_id"` // 关联业务ID
|
||||||
|
Reason string `gorm:"column:reason"` // 变更原因/备注
|
||||||
|
}
|
||||||
|
|
||||||
|
func (QuotaFlowLog) TableName() string {
|
||||||
|
return "quota_flow_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== VIP 流水 =====
|
||||||
|
|
||||||
|
type VIPFlowLog struct {
|
||||||
|
Model
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 用户ID
|
||||||
|
// ChangeType 变更类型 1=开通 2=续费 3=活动赠送 4=到期失效 5=人工调整 6=退款回收
|
||||||
|
ChangeType int8 `gorm:"column:change_type;uniqueIndex:uk_vip_flow_idem,priority:2"`
|
||||||
|
OldVIPType int8 `gorm:"column:old_vip_type"` // 变更前VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||||
|
NewVIPType int8 `gorm:"column:new_vip_type"` // 变更后VIP类型 1=非会员 2=月度会员 3=年度会员 4=体验会员
|
||||||
|
OldVIPExpire int64 `gorm:"column:old_vip_expire"` // 变更前到期时间(秒)
|
||||||
|
NewVIPExpire int64 `gorm:"column:new_vip_expire"` // 变更后到期时间(秒)
|
||||||
|
// OrderNo 关联订单号(购买类变更);人工调整类由 DAO 写入流水 UUID 满足联合唯一索引。
|
||||||
|
OrderNo string `gorm:"column:order_no;uniqueIndex:uk_vip_flow_idem,priority:1"`
|
||||||
|
Source int8 `gorm:"column:source"` // 变更来源 1=个人支付 2=活动赠送 3=系统 4=人工调整
|
||||||
|
Reason string `gorm:"column:reason"` // 变更原因/备注
|
||||||
|
}
|
||||||
|
|
||||||
|
func (VIPFlowLog) TableName() string {
|
||||||
|
return "vip_flow_logs"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 通知 =====
|
||||||
|
|
||||||
|
type UserNotification struct {
|
||||||
|
Model
|
||||||
|
NotificationUUID string `gorm:"column:notification_uuid"` // 消息UUID
|
||||||
|
UserID int64 `gorm:"column:user_id"` // 接收用户ID
|
||||||
|
MsgType int8 `gorm:"column:msg_type"` // 消息类型 1=点赞通知 2=订单通知 3=系统通知
|
||||||
|
SenderID int64 `gorm:"column:sender_id"` // 发送者ID 系统消息为0
|
||||||
|
Title string `gorm:"column:title"` // 消息标题
|
||||||
|
Content string `gorm:"column:content"` // 消息内容
|
||||||
|
IsRead int8 `gorm:"column:is_read"` // 是否已读 1=未读 2=已读
|
||||||
|
ReadTime int64 `gorm:"column:read_time"` // 阅读时间
|
||||||
|
}
|
||||||
|
|
||||||
|
func (UserNotification) TableName() string {
|
||||||
|
return "user_notifications"
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 点赞/收藏 =====
|
||||||
|
|
||||||
|
type Like struct {
|
||||||
|
Model
|
||||||
|
// LikeType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑点赞/取消点赞 upsert。
|
||||||
|
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=已取消
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Like) TableName() string {
|
||||||
|
return "likes"
|
||||||
|
}
|
||||||
|
|
||||||
|
type Collect struct {
|
||||||
|
Model
|
||||||
|
// CollectType 业务类型 1=社区穿搭;与 TargetID、UserID 组成联合唯一索引,支撑收藏/取消收藏 upsert。
|
||||||
|
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=已取消
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Collect) TableName() string {
|
||||||
|
return "collects"
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package mysqlmodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/plugin/soft_delete"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Model struct {
|
||||||
|
ID int64 `gorm:"column:id;primaryKey"` // 主键ID
|
||||||
|
CreatedAt int64 `gorm:"column:created_at"` // 创建时间
|
||||||
|
UpdatedAt int64 `gorm:"column:updated_at"` // 更新时间
|
||||||
|
DeletedAt soft_delete.DeletedAt `gorm:"column:deleted_at"` // 删除时间
|
||||||
|
CreatedBy int64 `gorm:"column:created_by"` // 创建人
|
||||||
|
UpdatedBy int64 `gorm:"column:updated_by"` // 更新人
|
||||||
|
DeletedBy int64 `gorm:"column:deleted_by"` // 删除人
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMysqlEngine 根据配置创建 GORM MySQL 连接并完成连接池初始化。
|
||||||
|
func NewMysqlEngine(mysqlSetting *setting.MysqlSettingS) (*gorm.DB, error) {
|
||||||
|
s := fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local&timeout=10s&readTimeout=10s&writeTimeout=10s",
|
||||||
|
mysqlSetting.UserName,
|
||||||
|
mysqlSetting.Password,
|
||||||
|
mysqlSetting.Host,
|
||||||
|
mysqlSetting.Port,
|
||||||
|
mysqlSetting.DBName,
|
||||||
|
)
|
||||||
|
db, err := gorm.Open(mysql.Open(s), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB, err := db.DB()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlDB.SetMaxIdleConns(mysqlSetting.MaxIdleConns)
|
||||||
|
sqlDB.SetMaxOpenConns(mysqlSetting.MaxOpenConns)
|
||||||
|
sqlDB.SetConnMaxLifetime(time.Duration(mysqlSetting.ConnMaxLifeTime) * time.Second)
|
||||||
|
sqlDB.SetConnMaxIdleTime(time.Duration(mysqlSetting.ConnMaxIdleTime) * time.Second)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := sqlDB.PingContext(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AutoMigrateAdminTables 仅迁移管理后台自建的表(admin_users)。
|
||||||
|
// 业务表结构由业务服务的 golang-migrate 维护,管理后台不做 AutoMigrate,避免误改业务表。
|
||||||
|
func AutoMigrateAdminTables(db *gorm.DB) error {
|
||||||
|
return db.AutoMigrate(&AdminUser{})
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package redisdao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redismodel"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// authTokenSAddScript 向管理员 token 集合加入 refresh_token,仅在集合由空变为非空时设置 TTL,
|
||||||
|
// 避免每次登录都刷新集合 TTL 导致旧 token 条目长期驻留。
|
||||||
|
const authTokenSAddScript = `
|
||||||
|
local added = redis.call('SADD', KEYS[1], ARGV[1])
|
||||||
|
if added == 1 and redis.call('SCARD', KEYS[1]) == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[2])
|
||||||
|
end
|
||||||
|
return added
|
||||||
|
`
|
||||||
|
|
||||||
|
// AuthTokenSet 登录成功后写入会话:
|
||||||
|
// - access_token -> admin_id
|
||||||
|
// - refresh_token -> admin_id
|
||||||
|
// - admin_id 的 token 集合加入 refresh_token(用于多端管理 / 登出)
|
||||||
|
func AuthTokenSet(ctx context.Context, adminID int64, accessToken, refreshToken string) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenSet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
|
||||||
|
refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
|
||||||
|
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
|
||||||
|
|
||||||
|
accessTTL := time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second
|
||||||
|
refreshTTL := time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second
|
||||||
|
|
||||||
|
pipe := global.RedisEngine.TxPipeline()
|
||||||
|
pipe.Set(ctx, accessKey, adminID, accessTTL)
|
||||||
|
pipe.Set(ctx, refreshKey, adminID, refreshTTL)
|
||||||
|
pipe.Eval(ctx, authTokenSAddScript, []string{adminTokensKey}, refreshToken, int(refreshTTL.Seconds()))
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthTokenDel 登出/失效:删除 access、refresh 映射,并从管理员 token 集合移除 refresh_token
|
||||||
|
func AuthTokenDel(ctx context.Context, adminID int64, accessToken, refreshToken string) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthTokenDel")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
accessKey := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
|
||||||
|
refreshKey := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
|
||||||
|
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
|
||||||
|
|
||||||
|
pipe := global.RedisEngine.TxPipeline()
|
||||||
|
pipe.Del(ctx, accessKey)
|
||||||
|
pipe.Del(ctx, refreshKey)
|
||||||
|
pipe.SRem(ctx, adminTokensKey, refreshToken)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthAdminIDByAccess 通过 access token 查询 admin_id,token 不存在或已过期返回 redis.Nil
|
||||||
|
func AuthAdminIDByAccess(ctx context.Context, accessToken string) (int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminIDByAccess")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
key := fmt.Sprintf(redismodel.KeyAuthAccess, accessToken)
|
||||||
|
adminID, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
}
|
||||||
|
return adminID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthAdminIDByRefresh 通过 refresh token 查询 admin_id,token 不存在或已过期返回 redis.Nil
|
||||||
|
func AuthAdminIDByRefresh(ctx context.Context, refreshToken string) (int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminIDByRefresh")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
key := fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken)
|
||||||
|
adminID, err := global.RedisEngine.Get(ctx, key).Int64()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
}
|
||||||
|
return adminID, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// AuthAdminTokensDelAll 失效某管理员全部 token(禁用/删除账号时调用)。
|
||||||
|
func AuthAdminTokensDelAll(ctx context.Context, adminID int64) error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.AuthAdminTokensDelAll")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
adminTokensKey := fmt.Sprintf(redismodel.KeyAuthUserTokens, adminID)
|
||||||
|
members, err := global.RedisEngine.SMembers(ctx, adminTokensKey).Result()
|
||||||
|
if err != nil && err != redis.Nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
pipe := global.RedisEngine.TxPipeline()
|
||||||
|
for _, refreshToken := range members {
|
||||||
|
pipe.Del(ctx, fmt.Sprintf(redismodel.KeyAuthRefresh, refreshToken))
|
||||||
|
}
|
||||||
|
// access token 没有全集索引(TTL 短,最多 AccessTokenExpire 秒后自然过期),
|
||||||
|
// 由 JWTAuth 的 DB 状态回查兜底拦截。
|
||||||
|
pipe.Del(ctx, adminTokensKey)
|
||||||
|
if _, err := pipe.Exec(ctx); err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package redisdao
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// businessRedisPrefix 业务服务(zt-closet-backend)的 Redis key 前缀。
|
||||||
|
// 管理后台推送通知时,必须自增业务服务所读的同一未读计数 key,
|
||||||
|
// 保证小程序端未读角标实时一致。要求管理后台与业务服务共用同一 Redis 库(config.yaml 的 Redis.DB)。
|
||||||
|
const businessRedisPrefix = "ztcloset:"
|
||||||
|
|
||||||
|
// KeyNotificationUnread 业务服务的用户未读通知计数 key(格式保持一致)。
|
||||||
|
var KeyNotificationUnread = businessRedisPrefix + "notification:unread:%d"
|
||||||
|
|
||||||
|
// notificationUnreadTTL 未读数 key 的过期时间,与业务服务保持一致。
|
||||||
|
const notificationUnreadTTL = 7 * 24 * time.Hour
|
||||||
|
|
||||||
|
// notificationUnreadIncrScript 首次自增时设置过期时间,保证 INCR 与 EXPIRE 原子执行。
|
||||||
|
var notificationUnreadIncrScript = redis.NewScript(`
|
||||||
|
local v = redis.call('INCR', KEYS[1])
|
||||||
|
if v == 1 then
|
||||||
|
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||||
|
end
|
||||||
|
return v
|
||||||
|
`)
|
||||||
|
|
||||||
|
// NotificationUnreadIncr 用户未读消息数 +1,返回最新值
|
||||||
|
func NotificationUnreadIncr(ctx context.Context, userID int64) (int64, error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "redisdao.NotificationUnreadIncr")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
key := fmt.Sprintf(KeyNotificationUnread, userID)
|
||||||
|
val, err := notificationUnreadIncrScript.Run(ctx, global.RedisEngine, []string{key}, int(notificationUnreadTTL.Seconds())).Int64()
|
||||||
|
if err != nil {
|
||||||
|
span.RecordError(err)
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
return val, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
package redismodel
|
||||||
|
|
||||||
|
// 认证模块:管理员 token 会话的 Redis key。
|
||||||
|
const (
|
||||||
|
KeyAuthAccess = RedisPrefix + "auth:access:%s" // access_token -> admin_id(String)
|
||||||
|
KeyAuthRefresh = RedisPrefix + "auth:refresh:%s" // refresh_token -> admin_id(String)
|
||||||
|
KeyAuthUserTokens = RedisPrefix + "auth:admin:tokens:%d" // admin_id -> token 集合(Set,支持多端登录)
|
||||||
|
)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package redismodel
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RedisPrefix 是所有 Redis key 的全局命名空间前缀,避免多服务共享 Redis 实例时发生 key 冲突。
|
||||||
|
const RedisPrefix = "ztclosetadmin:"
|
||||||
|
|
||||||
|
// NewRedisEngine 根据配置创建 Redis 客户端并验证连接。
|
||||||
|
func NewRedisEngine(s *setting.RedisSettingS) (*redis.Client, error) {
|
||||||
|
client := redis.NewClient(&redis.Options{
|
||||||
|
Addr: fmt.Sprintf("%s:%d", s.Host, s.Port),
|
||||||
|
Password: s.Password,
|
||||||
|
DB: s.DB,
|
||||||
|
PoolSize: s.PoolSize,
|
||||||
|
MinIdleConns: s.MinIdleConns,
|
||||||
|
MaxIdleConns: s.MaxIdleConns,
|
||||||
|
MinRetryBackoff: time.Duration(s.MinRetryBackoff) * time.Millisecond,
|
||||||
|
MaxRetryBackoff: time.Duration(s.MaxRetryBackoff) * time.Millisecond,
|
||||||
|
MaxRetries: s.MaxRetries,
|
||||||
|
// 连接/读写/池超时配置单位为秒(与 config.yaml 注释一致)。
|
||||||
|
DialTimeout: time.Duration(s.DialTimeout) * time.Second,
|
||||||
|
ReadTimeout: time.Duration(s.ReadTimeout) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(s.WriteTimeout) * time.Second,
|
||||||
|
PoolTimeout: time.Duration(s.PoolTimeout) * time.Second,
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := client.Ping(context.Background()).Err(); err != nil {
|
||||||
|
return nil, fmt.Errorf("redis ping failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("redis connected", "addr", fmt.Sprintf("%s:%d", s.Host, s.Port))
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminHandler 管理员账号相关接口。
|
||||||
|
type AdminHandler struct {
|
||||||
|
svc *service.AdminService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminHandler 创建管理员 handler。
|
||||||
|
func NewAdminHandler() *AdminHandler {
|
||||||
|
return &AdminHandler{svc: service.NewAdminService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 管理员登录(公开)。
|
||||||
|
func (h *AdminHandler) Login(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.AdminLoginRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.Login(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken 刷新 token(公开)。
|
||||||
|
func (h *AdminHandler) RefreshToken(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.AdminRefreshTokenRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.RefreshToken(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 登出(需鉴权)。
|
||||||
|
func (h *AdminHandler) Logout(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request struct {
|
||||||
|
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accessToken := bearerToken(c)
|
||||||
|
if e := h.svc.Logout(c.Request.Context(), accessToken, request.RefreshToken); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改自己密码(需鉴权)。
|
||||||
|
func (h *AdminHandler) ChangePassword(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.AdminChangePasswordRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, ok := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
response.ToErrorResponse(errcode.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := h.svc.ChangePassword(c.Request.Context(), adminID, &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 管理员列表(仅超级管理员)。
|
||||||
|
func (h *AdminHandler) List(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.AdminListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.List(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建管理员(仅超级管理员)。
|
||||||
|
func (h *AdminHandler) Create(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.AdminCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.Create(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新管理员(仅超级管理员)。
|
||||||
|
func (h *AdminHandler) Update(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法管理员 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request dto.AdminUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, ok := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
response.ToErrorResponse(errcode.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := h.svc.Update(c.Request.Context(), adminID, id, &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除管理员(仅超级管理员)。
|
||||||
|
func (h *AdminHandler) Delete(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法管理员 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, ok := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if !ok {
|
||||||
|
response.ToErrorResponse(errcode.Unauthorized)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if e := h.svc.Delete(c.Request.Context(), adminID, id); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// bearerToken 从 Authorization 头提取裸 token。
|
||||||
|
func bearerToken(c *gin.Context) string {
|
||||||
|
authHeader := c.GetHeader("Authorization")
|
||||||
|
parts := strings.SplitN(authHeader, " ", 2)
|
||||||
|
if len(parts) == 2 && parts[0] == "Bearer" {
|
||||||
|
return parts[1]
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentHandler 内容审核接口(穿搭/衣物/分享)。
|
||||||
|
type ContentHandler struct {
|
||||||
|
svc *service.ContentService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContentHandler 创建内容审核 handler。
|
||||||
|
func NewContentHandler() *ContentHandler {
|
||||||
|
return &ContentHandler{svc: service.NewContentService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitList 穿搭列表。
|
||||||
|
func (h *ContentHandler) OutfitList(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.OutfitListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.OutfitList(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitDetail 穿搭详情。
|
||||||
|
func (h *ContentHandler) OutfitDetail(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
out, e := h.svc.OutfitDetail(c.Request.Context(), c.Param("uuid"))
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitAudit 穿搭审核(通过/驳回)。
|
||||||
|
func (h *ContentHandler) OutfitAudit(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.OutfitAuditRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.OutfitAudit(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitBan 封禁穿搭。
|
||||||
|
func (h *ContentHandler) OutfitBan(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.OutfitBanRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.OutfitBan(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitUnban 解封穿搭。
|
||||||
|
func (h *ContentHandler) OutfitUnban(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.OutfitUnban(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesList 衣物列表。
|
||||||
|
func (h *ContentHandler) ClothesList(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.ClothesListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.ClothesList(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesDetail 衣物详情。
|
||||||
|
func (h *ContentHandler) ClothesDetail(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
out, e := h.svc.ClothesDetail(c.Request.Context(), c.Param("uuid"))
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareList 衣柜分享列表。
|
||||||
|
func (h *ContentHandler) ShareList(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.ClosetShareListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.ShareList(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareBan 封禁分享。
|
||||||
|
func (h *ContentHandler) ShareBan(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.ShareBan(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareUnban 解封分享。
|
||||||
|
func (h *ContentHandler) ShareUnban(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.ShareUnban(c.Request.Context(), adminID, c.Param("uuid")); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DashboardHandler 仪表盘统计接口。
|
||||||
|
type DashboardHandler struct {
|
||||||
|
svc *service.DashboardService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDashboardHandler 创建仪表盘 handler。
|
||||||
|
func NewDashboardHandler() *DashboardHandler {
|
||||||
|
return &DashboardHandler{svc: service.NewDashboardService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 仪表盘核心指标。
|
||||||
|
func (h *DashboardHandler) Summary(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
out, e := h.svc.Summary(c.Request.Context())
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trend 近 N 天趋势。
|
||||||
|
func (h *DashboardHandler) Trend(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.TrendRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.Trends(c.Request.Context(), request.Days)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoodsHandler 商品管理接口。
|
||||||
|
type GoodsHandler struct {
|
||||||
|
svc *service.GoodsService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoodsHandler 创建商品 handler。
|
||||||
|
func NewGoodsHandler() *GoodsHandler {
|
||||||
|
return &GoodsHandler{svc: service.NewGoodsService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 商品列表(含下架)。
|
||||||
|
func (h *GoodsHandler) List(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.GoodsListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.List(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建商品。
|
||||||
|
func (h *GoodsHandler) Create(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.GoodsCreateRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
out, e := h.svc.Create(c.Request.Context(), adminID, &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新商品。
|
||||||
|
func (h *GoodsHandler) Update(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request dto.GoodsUpdateRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.Update(c.Request.Context(), adminID, id, &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 商品上下架。
|
||||||
|
func (h *GoodsHandler) UpdateStatus(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request dto.GoodsStatusRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.UpdateStatus(c.Request.Context(), adminID, id, &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除商品。
|
||||||
|
func (h *GoodsHandler) Delete(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || id <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法商品 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.Delete(c.Request.Context(), adminID, id); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NotificationHandler 通知推送接口。
|
||||||
|
type NotificationHandler struct {
|
||||||
|
svc *service.NotificationService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotificationHandler 创建通知 handler。
|
||||||
|
func NewNotificationHandler() *NotificationHandler {
|
||||||
|
return &NotificationHandler{svc: service.NewNotificationService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 向单个用户发送系统通知。
|
||||||
|
func (h *NotificationHandler) Send(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.NotificationSendRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.Send(c.Request.Context(), adminID, &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast 全量广播系统通知。
|
||||||
|
func (h *NotificationHandler) Broadcast(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.NotificationBroadcastRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
out, e := h.svc.Broadcast(c.Request.Context(), adminID, &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderHandler 订单与退款管理接口。
|
||||||
|
type OrderHandler struct {
|
||||||
|
svc *service.OrderService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderHandler 创建订单 handler。
|
||||||
|
func NewOrderHandler() *OrderHandler {
|
||||||
|
return &OrderHandler{svc: service.NewOrderService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 订单列表。
|
||||||
|
func (h *OrderHandler) List(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.OrderListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.List(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 订单详情。
|
||||||
|
func (h *OrderHandler) Get(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
out, e := h.svc.Get(c.Request.Context(), c.Param("orderNo"))
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundList 退款单列表。
|
||||||
|
func (h *OrderHandler) RefundList(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.RefundListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.RefundList(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundApprove 退款审核通过。
|
||||||
|
func (h *OrderHandler) RefundApprove(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.RefundAuditRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.RefundApprove(c.Request.Context(), adminID, c.Param("refundNo"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundReject 退款审核驳回。
|
||||||
|
func (h *OrderHandler) RefundReject(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.RefundAuditRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.RefundReject(c.Request.Context(), adminID, c.Param("refundNo"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package v1
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/app"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserHandler 用户管理接口。
|
||||||
|
type UserHandler struct {
|
||||||
|
svc *service.UserService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserHandler 创建用户管理 handler。
|
||||||
|
func NewUserHandler() *UserHandler {
|
||||||
|
return &UserHandler{svc: service.NewUserService()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 用户分页列表。
|
||||||
|
func (h *UserHandler) List(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.UserListRequest
|
||||||
|
if err := c.ShouldBindQuery(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, e := h.svc.List(c.Request.Context(), &request)
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail 用户详情(含四类额度)。
|
||||||
|
func (h *UserHandler) Detail(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
out, e := h.svc.Detail(c.Request.Context(), c.Param("uuid"))
|
||||||
|
if e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban 封禁用户。
|
||||||
|
func (h *UserHandler) Ban(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.UserBanRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.Ban(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unban 解封用户。
|
||||||
|
func (h *UserHandler) Unban(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.UserUnbanRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.Unban(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaAdjust 额度发放(有有效期额度凭证)。
|
||||||
|
func (h *UserHandler) QuotaAdjust(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.UserQuotaAdjustRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.QuotaAdjust(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPAdjust 会员人工调整。
|
||||||
|
func (h *UserHandler) VIPAdjust(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
var request dto.UserVIPAdjustRequest
|
||||||
|
if err := c.ShouldBindJSON(&request); err != nil {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.VIPAdjust(c.Request.Context(), adminID, c.Param("uuid"), &request); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantRevoke 回收额度凭证。
|
||||||
|
func (h *UserHandler) GrantRevoke(c *gin.Context) {
|
||||||
|
response := app.NewResponse(c)
|
||||||
|
grantID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||||
|
if err != nil || grantID <= 0 {
|
||||||
|
response.ToErrorResponse(errcode.InvalidParams.WithDetails("非法凭证 ID"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
adminID, _ := middleware.GetAdminID(c.Request.Context())
|
||||||
|
if e := h.svc.GrantRevoke(c.Request.Context(), adminID, c.Param("uuid"), grantID); e != nil {
|
||||||
|
response.ToErrorResponse(e)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
response.ToResponse(nil)
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
v1 "git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/routers/api/v1"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。
|
||||||
|
func NewRouter() *gin.Engine {
|
||||||
|
r := gin.New()
|
||||||
|
|
||||||
|
// 中间件链:Recovery -> RequestID -> OTel -> Slog -> CORS
|
||||||
|
r.Use(
|
||||||
|
gin.Recovery(),
|
||||||
|
middleware.RequestID(),
|
||||||
|
middleware.GinOTelMiddleware(),
|
||||||
|
middleware.GinSlogMiddleware(),
|
||||||
|
middleware.CORS(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 健康检查
|
||||||
|
r.GET("/ping", func(c *gin.Context) {
|
||||||
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// handler 实例
|
||||||
|
adminHandler := v1.NewAdminHandler()
|
||||||
|
dashboardHandler := v1.NewDashboardHandler()
|
||||||
|
userHandler := v1.NewUserHandler()
|
||||||
|
goodsHandler := v1.NewGoodsHandler()
|
||||||
|
orderHandler := v1.NewOrderHandler()
|
||||||
|
contentHandler := v1.NewContentHandler()
|
||||||
|
notificationHandler := v1.NewNotificationHandler()
|
||||||
|
|
||||||
|
apiV1 := r.Group("/api/v1")
|
||||||
|
|
||||||
|
// ===== 公开路由(无需登录)=====
|
||||||
|
{
|
||||||
|
apiV1.POST("/admin/login", adminHandler.Login)
|
||||||
|
apiV1.POST("/admin/refresh-token", adminHandler.RefreshToken)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 鉴权路由(需登录)=====
|
||||||
|
auth := apiV1.Group("")
|
||||||
|
auth.Use(middleware.JWTAuth())
|
||||||
|
{
|
||||||
|
// 管理员(自己)
|
||||||
|
auth.POST("/admin/logout", adminHandler.Logout)
|
||||||
|
auth.PUT("/admin/password", adminHandler.ChangePassword)
|
||||||
|
|
||||||
|
// 仪表盘
|
||||||
|
auth.GET("/dashboard/summary", dashboardHandler.Summary)
|
||||||
|
auth.GET("/dashboard/trends", dashboardHandler.Trend)
|
||||||
|
|
||||||
|
// 用户管理
|
||||||
|
auth.GET("/users", userHandler.List)
|
||||||
|
auth.GET("/users/:uuid", userHandler.Detail)
|
||||||
|
auth.PUT("/users/:uuid/ban", userHandler.Ban)
|
||||||
|
auth.PUT("/users/:uuid/unban", userHandler.Unban)
|
||||||
|
auth.PUT("/users/:uuid/quota", userHandler.QuotaAdjust)
|
||||||
|
auth.PUT("/users/:uuid/vip", userHandler.VIPAdjust)
|
||||||
|
auth.DELETE("/users/:uuid/grants/:id", userHandler.GrantRevoke)
|
||||||
|
|
||||||
|
// 商品管理
|
||||||
|
auth.GET("/goods", goodsHandler.List)
|
||||||
|
auth.POST("/goods", goodsHandler.Create)
|
||||||
|
auth.PUT("/goods/:id", goodsHandler.Update)
|
||||||
|
auth.PUT("/goods/:id/status", goodsHandler.UpdateStatus)
|
||||||
|
auth.DELETE("/goods/:id", goodsHandler.Delete)
|
||||||
|
|
||||||
|
// 订单与退款
|
||||||
|
auth.GET("/orders", orderHandler.List)
|
||||||
|
auth.GET("/orders/:orderNo", orderHandler.Get)
|
||||||
|
auth.GET("/refunds", orderHandler.RefundList)
|
||||||
|
auth.POST("/refunds/:refundNo/approve", orderHandler.RefundApprove)
|
||||||
|
auth.POST("/refunds/:refundNo/reject", orderHandler.RefundReject)
|
||||||
|
|
||||||
|
// 内容审核
|
||||||
|
auth.GET("/outfits", contentHandler.OutfitList)
|
||||||
|
auth.GET("/outfits/:uuid", contentHandler.OutfitDetail)
|
||||||
|
auth.POST("/outfits/:uuid/audit", contentHandler.OutfitAudit)
|
||||||
|
auth.POST("/outfits/:uuid/ban", contentHandler.OutfitBan)
|
||||||
|
auth.POST("/outfits/:uuid/unban", contentHandler.OutfitUnban)
|
||||||
|
auth.GET("/clothes", contentHandler.ClothesList)
|
||||||
|
auth.GET("/clothes/:uuid", contentHandler.ClothesDetail)
|
||||||
|
auth.GET("/closet-shares", contentHandler.ShareList)
|
||||||
|
auth.POST("/closet-shares/:uuid/ban", contentHandler.ShareBan)
|
||||||
|
auth.POST("/closet-shares/:uuid/unban", contentHandler.ShareUnban)
|
||||||
|
|
||||||
|
// 通知推送
|
||||||
|
auth.POST("/notifications", notificationHandler.Send)
|
||||||
|
auth.POST("/notifications/broadcast", notificationHandler.Broadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 超管专属路由 =====
|
||||||
|
super := apiV1.Group("")
|
||||||
|
super.Use(middleware.JWTAuth(), middleware.AdminSuperAuth())
|
||||||
|
{
|
||||||
|
super.GET("/admins", adminHandler.List)
|
||||||
|
super.POST("/admins", adminHandler.Create)
|
||||||
|
super.PUT("/admins/:id", adminHandler.Update)
|
||||||
|
super.DELETE("/admins/:id", adminHandler.Delete)
|
||||||
|
}
|
||||||
|
|
||||||
|
return r
|
||||||
|
}
|
||||||
@@ -0,0 +1,379 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/lzcwyjwt"
|
||||||
|
"golang.org/x/crypto/bcrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AdminService 管理员账号业务服务。
|
||||||
|
type AdminService struct {
|
||||||
|
adminDao *mysqldao.AdminDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAdminService 创建管理员服务。
|
||||||
|
func NewAdminService() *AdminService {
|
||||||
|
return &AdminService{
|
||||||
|
adminDao: mysqldao.NewAdminDao(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Login 管理员登录:账号密码校验 → 签发双 token → 会话入库 → 返回管理员信息。
|
||||||
|
func (s *AdminService) Login(ctx context.Context, request *dto.AdminLoginRequest) (*dto.AdminLoginResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogin")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
admin, err := s.adminDao.AdminGetByUsername(ctx, request.Username)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
// 账号不存在与密码错误统一返回同一错误,避免账号枚举攻击。
|
||||||
|
return nil, errcode.AdminLoginFailed
|
||||||
|
}
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if admin.Status != AdminStatusEnabled {
|
||||||
|
return nil, errcode.AdminDisabled
|
||||||
|
}
|
||||||
|
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.Password)); cerr != nil {
|
||||||
|
return nil, errcode.AdminLoginFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(admin.ID)
|
||||||
|
if terr != nil {
|
||||||
|
return nil, errcode.UnauthorizedTokenGenerate
|
||||||
|
}
|
||||||
|
if aerr := redisdao.AuthTokenSet(ctx, admin.ID, accessToken, refreshToken); aerr != nil {
|
||||||
|
// Redis 故障降级放行(与鉴权中间件 fail-open 策略一致)。
|
||||||
|
slog.WarnContext(ctx, "set admin auth token failed, allow by fallback", "error", aerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// best-effort 更新最近登录时间。
|
||||||
|
_ = s.adminDao.AdminUpdate(ctx, admin.ID, map[string]any{"last_login_time": nowUnix()})
|
||||||
|
|
||||||
|
return &dto.AdminLoginResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||||
|
AdminInfo: buildAdminInfo(admin),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshToken 使用 refresh token 刷新访问令牌(需携带旧 access token 精确吊销旧会话)。
|
||||||
|
func (s *AdminService) RefreshToken(ctx context.Context, request *dto.AdminRefreshTokenRequest) (*dto.AdminRefreshTokenResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefreshToken")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
|
||||||
|
return nil, errcode.UnauthorizedTokenTimeout
|
||||||
|
}
|
||||||
|
return nil, errcode.UnauthorizedTokenError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 refresh token 仍在有效会话中。
|
||||||
|
uid, aerr := redisdao.AuthAdminIDByRefresh(ctx, request.RefreshToken)
|
||||||
|
if aerr != nil {
|
||||||
|
if isCacheMiss(aerr) {
|
||||||
|
return nil, errcode.UnauthorizedTokenError
|
||||||
|
}
|
||||||
|
return nil, errcode.CacheError
|
||||||
|
}
|
||||||
|
if uid != claims.AdminID {
|
||||||
|
return nil, errcode.UnauthorizedTokenError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 管理员状态回查(禁用后 refresh 也立即失效)。
|
||||||
|
admin, dbErr := s.adminDao.AdminGetByID(ctx, claims.AdminID)
|
||||||
|
if dbErr != nil {
|
||||||
|
return nil, errcode.AdminNotFound
|
||||||
|
}
|
||||||
|
if admin.Status != AdminStatusEnabled {
|
||||||
|
return nil, errcode.AdminDisabled
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(claims.AdminID)
|
||||||
|
if terr != nil {
|
||||||
|
return nil, errcode.UnauthorizedTokenGenerate
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失效旧会话:吊销旧 access + 旧 refresh,防止旧 token 泄露后被继续使用。
|
||||||
|
if request.AccessToken == "" {
|
||||||
|
return nil, errcode.InvalidParams.WithDetails("缺少旧 access token")
|
||||||
|
}
|
||||||
|
oldUID, oerr := redisdao.AuthAdminIDByAccess(ctx, request.AccessToken)
|
||||||
|
if oerr != nil && !isCacheMiss(oerr) {
|
||||||
|
return nil, errcode.CacheError
|
||||||
|
}
|
||||||
|
if oerr == nil && oldUID != claims.AdminID {
|
||||||
|
return nil, errcode.UnauthorizedTokenError
|
||||||
|
}
|
||||||
|
_ = redisdao.AuthTokenDel(ctx, claims.AdminID, request.AccessToken, request.RefreshToken)
|
||||||
|
if serr := redisdao.AuthTokenSet(ctx, claims.AdminID, accessToken, refreshToken); serr != nil {
|
||||||
|
return nil, errcode.CacheError
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.AdminRefreshTokenResponse{
|
||||||
|
AccessToken: accessToken,
|
||||||
|
RefreshToken: refreshToken,
|
||||||
|
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout 登出:吊销当前会话(access + refresh)。
|
||||||
|
func (s *AdminService) Logout(ctx context.Context, accessToken, refreshToken string) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminLogout")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
adminID, e := requireAdminID(ctx)
|
||||||
|
if e != nil {
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
if err := redisdao.AuthTokenDel(ctx, adminID, accessToken, refreshToken); err != nil {
|
||||||
|
slog.WarnContext(ctx, "admin logout delete token failed", "error", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePassword 修改自己密码(校验原密码,新密码 bcrypt 入库)。
|
||||||
|
func (s *AdminService) ChangePassword(ctx context.Context, adminID int64, request *dto.AdminChangePasswordRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminChangePassword")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
admin, err := s.adminDao.AdminGetByID(ctx, adminID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.AdminNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if cerr := bcrypt.CompareHashAndPassword([]byte(admin.PasswordHash), []byte(request.OldPassword)); cerr != nil {
|
||||||
|
return errcode.AdminOldPasswordWrong
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, herr := bcrypt.GenerateFromPassword([]byte(request.NewPassword), bcrypt.DefaultCost)
|
||||||
|
if herr != nil {
|
||||||
|
return errcode.AdminPasswordInvalid
|
||||||
|
}
|
||||||
|
if err := s.adminDao.AdminUpdate(ctx, adminID, map[string]any{"password_hash": string(hash)}); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 改密后失效全部会话,强制重新登录。
|
||||||
|
if derr := redisdao.AuthAdminTokensDelAll(ctx, adminID); derr != nil {
|
||||||
|
slog.WarnContext(ctx, "invalidate admin tokens failed after password change", "error", derr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询管理员列表(仅超级管理员)。
|
||||||
|
func (s *AdminService) List(ctx context.Context, request *dto.AdminListRequest) (*dto.AdminListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.adminDao.AdminList(ctx, request.Status, page, pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.AdminInfo, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildAdminInfo(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.AdminListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建管理员账号(仅超级管理员)。
|
||||||
|
func (s *AdminService) Create(ctx context.Context, request *dto.AdminCreateRequest) (*dto.AdminInfo, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminCreate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
username := strings.TrimSpace(request.Username)
|
||||||
|
if username == "" {
|
||||||
|
return nil, errcode.InvalidParams.WithDetails("账号不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户名唯一性校验(含软删,防历史账号占用)。
|
||||||
|
if _, err := s.adminDao.AdminGetByUsername(ctx, username); err == nil {
|
||||||
|
return nil, errcode.AdminUsernameExists
|
||||||
|
} else if !isNotFound(err) {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||||
|
if herr != nil {
|
||||||
|
return nil, errcode.AdminPasswordInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := &mysqlmodel.AdminUser{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Nickname: strings.TrimSpace(request.Nickname),
|
||||||
|
Role: request.Role,
|
||||||
|
Status: AdminStatusEnabled,
|
||||||
|
}
|
||||||
|
if admin.Nickname == "" {
|
||||||
|
admin.Nickname = username
|
||||||
|
}
|
||||||
|
if err := s.adminDao.AdminCreate(ctx, admin); err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
info := buildAdminInfo(admin)
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新管理员账号(仅超级管理员;不能操作自己以外的超级管理员)。
|
||||||
|
func (s *AdminService) Update(ctx context.Context, adminID, targetID int64, request *dto.AdminUpdateRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUpdate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
target, err := s.adminDao.AdminGetByID(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.AdminNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保护规则:超级管理员之间不能互操作;目标为超级管理员且非本人时拒绝。
|
||||||
|
if target.Role == AdminRoleSuper && targetID != adminID {
|
||||||
|
return errcode.AdminCannotModifySuper
|
||||||
|
}
|
||||||
|
// 不能禁用自己。
|
||||||
|
if adminID == targetID && request.Status == AdminStatusDisabled {
|
||||||
|
return errcode.AdminCannotDisableSelf
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{}
|
||||||
|
if strings.TrimSpace(request.Nickname) != "" {
|
||||||
|
updates["nickname"] = strings.TrimSpace(request.Nickname)
|
||||||
|
}
|
||||||
|
if request.Role != 0 && request.Role != target.Role {
|
||||||
|
updates["role"] = request.Role
|
||||||
|
}
|
||||||
|
if request.Status != 0 && request.Status != target.Status {
|
||||||
|
updates["status"] = request.Status
|
||||||
|
}
|
||||||
|
if request.Password != "" {
|
||||||
|
hash, herr := bcrypt.GenerateFromPassword([]byte(request.Password), bcrypt.DefaultCost)
|
||||||
|
if herr != nil {
|
||||||
|
return errcode.AdminPasswordInvalid
|
||||||
|
}
|
||||||
|
updates["password_hash"] = string(hash)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(updates) > 0 {
|
||||||
|
if err := s.adminDao.AdminUpdate(ctx, targetID, updates); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
// 禁用/改密后失效目标账号全部会话,立即生效。
|
||||||
|
if request.Status == AdminStatusDisabled || request.Password != "" {
|
||||||
|
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
|
||||||
|
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除管理员账号(仅超级管理员;不能删除自己与其他超级管理员)。
|
||||||
|
func (s *AdminService) Delete(ctx context.Context, adminID, targetID int64) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminDelete")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if adminID == targetID {
|
||||||
|
return errcode.AdminCannotDeleteSelf
|
||||||
|
}
|
||||||
|
|
||||||
|
target, err := s.adminDao.AdminGetByID(ctx, targetID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.AdminNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if target.Role == AdminRoleSuper {
|
||||||
|
return errcode.AdminCannotModifySuper
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.adminDao.AdminSoftDelete(ctx, targetID); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if derr := redisdao.AuthAdminTokensDelAll(ctx, targetID); derr != nil {
|
||||||
|
slog.WarnContext(ctx, "invalidate admin tokens failed", "admin_id", targetID, "error", derr)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnsureInitialSuperAdmin 幂等创建初始超级管理员(配置 Admin.InitialUsername/InitialPassword)。
|
||||||
|
// 已存在则跳过;账号被软删则视为不存在重新创建。
|
||||||
|
func EnsureInitialSuperAdmin(ctx context.Context) {
|
||||||
|
if global.AdminSetting == nil ||
|
||||||
|
strings.TrimSpace(global.AdminSetting.InitialUsername) == "" ||
|
||||||
|
global.AdminSetting.InitialPassword == "" {
|
||||||
|
slog.WarnContext(ctx, "admin initial account not configured, skip seed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
username := strings.TrimSpace(global.AdminSetting.InitialUsername)
|
||||||
|
svc := NewAdminService()
|
||||||
|
|
||||||
|
// 已存在(含被禁用)则跳过,避免覆盖运营修改后的账号。
|
||||||
|
if admin, err := svc.adminDao.AdminGetByUsername(ctx, username); err == nil {
|
||||||
|
if admin.DeletedAt == 0 {
|
||||||
|
slog.InfoContext(ctx, "initial admin already exists, skip seed", "username", username)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else if !isNotFound(err) {
|
||||||
|
slog.ErrorContext(ctx, "check initial admin failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
hash, herr := bcrypt.GenerateFromPassword([]byte(global.AdminSetting.InitialPassword), bcrypt.DefaultCost)
|
||||||
|
if herr != nil {
|
||||||
|
slog.ErrorContext(ctx, "hash initial admin password failed", "error", herr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
admin := &mysqlmodel.AdminUser{
|
||||||
|
Username: username,
|
||||||
|
PasswordHash: string(hash),
|
||||||
|
Nickname: username,
|
||||||
|
Role: AdminRoleSuper,
|
||||||
|
Status: AdminStatusEnabled,
|
||||||
|
}
|
||||||
|
if err := svc.adminDao.AdminCreate(ctx, admin); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "create initial admin failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.InfoContext(ctx, "initial admin created", "username", username)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdminInfo 将管理员模型映射为响应信息(密码哈希不出网)。
|
||||||
|
func buildAdminInfo(a *mysqlmodel.AdminUser) dto.AdminInfo {
|
||||||
|
return dto.AdminInfo{
|
||||||
|
ID: a.ID,
|
||||||
|
Username: a.Username,
|
||||||
|
Nickname: a.Nickname,
|
||||||
|
Role: a.Role,
|
||||||
|
Status: a.Status,
|
||||||
|
LastLoginTime: a.LastLoginTime,
|
||||||
|
CreatedAt: a.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
// 本文件集中定义管理后台各业务域的状态/类型枚举常量,消除魔法数字。
|
||||||
|
// 取值与业务服务(zt-closet-backend)internal/service/constants.go 及各表字段注释保持一致。
|
||||||
|
|
||||||
|
// ===== 管理员 AdminUser =====
|
||||||
|
const (
|
||||||
|
AdminRoleSuper int8 = 1 // 超级管理员
|
||||||
|
AdminRoleNormal int8 = 2 // 普通管理员
|
||||||
|
|
||||||
|
AdminStatusEnabled int8 = 1 // 启用
|
||||||
|
AdminStatusDisabled int8 = 2 // 禁用
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 用户 User =====
|
||||||
|
const (
|
||||||
|
UserStatusNormal int8 = 1 // 正常
|
||||||
|
UserStatusTempBan int8 = 2 // 临时禁用
|
||||||
|
UserStatusPermBan int8 = 3 // 永久禁用
|
||||||
|
UserStatusDeactivated int8 = 4 // 已注销
|
||||||
|
|
||||||
|
VIPTypeNone int8 = 1 // 非会员
|
||||||
|
VIPTypeMonthly int8 = 2 // 月度会员
|
||||||
|
VIPTypeYearly int8 = 3 // 年度会员
|
||||||
|
VIPTypeTrial int8 = 4 // 体验会员
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 配额 Quota =====
|
||||||
|
const (
|
||||||
|
QuotaTypeCos int8 = 1 // COS 上传
|
||||||
|
QuotaTypeAI int8 = 2 // AI 推荐
|
||||||
|
QuotaTypeOutfitShare int8 = 3 // 社区分享
|
||||||
|
QuotaTypeClosetShare int8 = 4 // 衣柜分享
|
||||||
|
|
||||||
|
// 额度凭证来源(quota_grants.source)
|
||||||
|
QuotaGrantSourceMember int8 = 1 // 会员包(跟随 VIP 有效期)
|
||||||
|
QuotaGrantSourceAddon int8 = 2 // 加量包(独立有效期)
|
||||||
|
QuotaGrantSourceAdReward int8 = 3 // 广告激励(当天有效)
|
||||||
|
|
||||||
|
QuotaChangeTargetAdjust int8 = 1 // 配额调整
|
||||||
|
QuotaChangeTargetUse int8 = 2 // 使用
|
||||||
|
|
||||||
|
QuotaChangeTypeManual int8 = 5 // 人工调整
|
||||||
|
)
|
||||||
|
|
||||||
|
// 免费额度内置兜底值(与业务服务 DefaultQuota* 对齐;仅当 Quota 配置段未配置时使用)。
|
||||||
|
const (
|
||||||
|
DefaultQuotaCos int64 = 20 // 免费上传额度(永久)
|
||||||
|
DefaultQuotaAI int64 = 3 // 免费 AI 推荐额度(每日)
|
||||||
|
DefaultQuotaOutfitShare int64 = 10 // 免费社区分享额度(每日)
|
||||||
|
DefaultQuotaClosetShare int64 = 5 // 免费衣柜分享额度(每日)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== VIP 流水 =====
|
||||||
|
const (
|
||||||
|
VIPChangeTypeManual int8 = 5 // 人工调整
|
||||||
|
|
||||||
|
VIPSourceManual int8 = 4 // 人工调整
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 商品 Goods =====
|
||||||
|
const (
|
||||||
|
GoodsTypeMember int8 = 1 // 会员包
|
||||||
|
GoodsTypeUpload int8 = 2 // 上传包
|
||||||
|
GoodsTypeAIRecommend int8 = 3 // AI 推荐包
|
||||||
|
GoodsTypeClosetShare int8 = 4 // 衣柜分享包
|
||||||
|
GoodsTypeOutfitShare int8 = 5 // 搭配分享包
|
||||||
|
|
||||||
|
GoodsStatusOnShelf int8 = 1 // 上架
|
||||||
|
GoodsStatusOffShelf int8 = 2 // 下架
|
||||||
|
|
||||||
|
// 虚拟支付道具发布状态
|
||||||
|
GoodsProductUnpublished int8 = 0 // 未发布
|
||||||
|
GoodsProductPublishing int8 = 1 // 发布中(上传/发布任务处理中)
|
||||||
|
GoodsProductPublished int8 = 2 // 已发布
|
||||||
|
GoodsProductPublishFail int8 = 3 // 发布失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 订单 Order =====
|
||||||
|
const (
|
||||||
|
OrderStatusPending int8 = 1 // 待支付
|
||||||
|
OrderStatusPaid int8 = 2 // 已支付
|
||||||
|
OrderStatusCancelled int8 = 3 // 已取消
|
||||||
|
OrderStatusExpired int8 = 4 // 已过期
|
||||||
|
OrderStatusRefunded int8 = 5 // 已退款
|
||||||
|
|
||||||
|
PayChannelWechat int8 = 1 // 微信支付(普通 JSAPI)
|
||||||
|
PayChannelVirtual int8 = 2 // 微信虚拟支付(XPay)
|
||||||
|
|
||||||
|
// 虚拟支付平台(由前端按 systemInfo.platform 上报)
|
||||||
|
XPayPlatformAndroid = "android"
|
||||||
|
XPayPlatformIOS = "ios"
|
||||||
|
XPayPlatformHarmony = "harmony"
|
||||||
|
XPayPlatformWindows = "windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 退款 Refund =====
|
||||||
|
const (
|
||||||
|
RefundStatusPending int8 = 0 // 待处理
|
||||||
|
RefundStatusProcessing int8 = 1 // 退款中
|
||||||
|
RefundStatusSuccess int8 = 2 // 退款成功
|
||||||
|
RefundStatusFailed int8 = 3 // 退款失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 穿搭 Outfit =====
|
||||||
|
const (
|
||||||
|
OutfitSourceUser int8 = 1 // 用户创建
|
||||||
|
OutfitSourceAI int8 = 2 // AI 推荐
|
||||||
|
|
||||||
|
OutfitPrivate int8 = 1 // 不公开
|
||||||
|
OutfitPublic int8 = 2 // 公开
|
||||||
|
|
||||||
|
OutfitAuditPending int8 = 1 // 待审核
|
||||||
|
OutfitAuditPass int8 = 2 // 审核通过
|
||||||
|
OutfitAuditReject int8 = 3 // 审核不通过
|
||||||
|
|
||||||
|
OutfitStatusNormal int8 = 1 // 正常
|
||||||
|
OutfitStatusBanned int8 = 2 // 封禁
|
||||||
|
OutfitStatusUnsaved int8 = 3 // 待用户保存
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 衣柜分享 ClosetShare =====
|
||||||
|
const (
|
||||||
|
ShareTypePartial int8 = 1 // 部分衣物分享
|
||||||
|
ShareTypeSnapshot int8 = 2 // 全量快照分享
|
||||||
|
|
||||||
|
ShareStatusNormal int8 = 1 // 正常
|
||||||
|
ShareStatusCancelled int8 = 2 // 用户取消
|
||||||
|
ShareStatusExpired int8 = 3 // 已过期
|
||||||
|
ShareStatusBanned int8 = 4 // 管理员封禁
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 衣物 Clothes 图片处理状态 =====
|
||||||
|
const (
|
||||||
|
ImageProcessMatting int8 = 1 // 抠图中
|
||||||
|
ImageProcessClassifying int8 = 2 // 分类中
|
||||||
|
ImageProcessQueuing int8 = 3 // 排队中
|
||||||
|
ImageProcessSuccess int8 = 4 // 成功
|
||||||
|
ImageProcessFailed int8 = 5 // 失败
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 通知 Notification =====
|
||||||
|
const (
|
||||||
|
NotificationTypeSystem int8 = 3 // 系统通知
|
||||||
|
|
||||||
|
NotificationUnread int8 = 1 // 未读
|
||||||
|
NotificationRead int8 = 2 // 已读
|
||||||
|
|
||||||
|
NotificationSenderSystem int64 = 0 // 系统发送者 ID
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 审核日志 AuditLog =====
|
||||||
|
const (
|
||||||
|
AuditResultPass int8 = 1 // 通过
|
||||||
|
AuditResultReject int8 = 2 // 驳回
|
||||||
|
)
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/cos"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ContentService 内容审核服务(穿搭/衣物/衣柜分享)。
|
||||||
|
type ContentService struct {
|
||||||
|
outfitDao *mysqldao.OutfitDao
|
||||||
|
clothesDao *mysqldao.ClothesDao
|
||||||
|
shareDao *mysqldao.ClosetShareDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewContentService 创建内容审核服务。
|
||||||
|
func NewContentService() *ContentService {
|
||||||
|
return &ContentService{
|
||||||
|
outfitDao: mysqldao.NewOutfitDao(),
|
||||||
|
clothesDao: mysqldao.NewClothesDao(),
|
||||||
|
shareDao: mysqldao.NewClosetShareDao(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitList 分页查询穿搭(管理端全量视角)。
|
||||||
|
func (s *ContentService) OutfitList(ctx context.Context, request *dto.OutfitListRequest) (*dto.OutfitListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.outfitDao.OutfitList(ctx, mysqldao.OutfitListParams{
|
||||||
|
UserID: request.UserID,
|
||||||
|
IsPublic: request.IsPublic,
|
||||||
|
AuditStatus: request.AuditStatus,
|
||||||
|
Status: request.Status,
|
||||||
|
Keyword: strings.TrimSpace(request.Keyword),
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.OutfitListItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildOutfitListItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.OutfitListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitDetail 穿搭详情(含衣物明细)。
|
||||||
|
func (s *ContentService) OutfitDetail(ctx context.Context, outfitUUID string) (*dto.OutfitDetailResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitDetail")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return nil, errcode.OutfitNotFound
|
||||||
|
}
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items, err := s.outfitDao.OutfitItemListByOutfitID(ctx, outfit.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
clothesIDs := make([]int64, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
clothesIDs = append(clothesIDs, it.ClothesID)
|
||||||
|
}
|
||||||
|
clothesList, err := s.clothesDao.ClothesListByIDs(ctx, clothesIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
// 衣物可能被软删,按 ID 索引过滤。
|
||||||
|
clothesMap := make(map[int64]mysqlmodel.Clothes, len(clothesList))
|
||||||
|
for _, cl := range clothesList {
|
||||||
|
clothesMap[cl.ID] = cl
|
||||||
|
}
|
||||||
|
|
||||||
|
detailItems := make([]dto.OutfitClothesItem, 0, len(items))
|
||||||
|
for _, it := range items {
|
||||||
|
cl, ok := clothesMap[it.ClothesID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detailItems = append(detailItems, dto.OutfitClothesItem{
|
||||||
|
ClothesUUID: cl.ClothesUUID,
|
||||||
|
Name: cl.Name,
|
||||||
|
CategoryMain: cl.CategoryMain,
|
||||||
|
CategorySub: cl.CategorySub,
|
||||||
|
ProcessedImage: cos.GetCosUrl(cl.ProcessedImage),
|
||||||
|
ImageProcessStatus: cl.ImageProcessStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.OutfitDetailResponse{
|
||||||
|
OutfitListItem: buildOutfitListItem(outfit),
|
||||||
|
Items: detailItems,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitAudit 穿搭审核:通过(audit 1→2)/驳回(audit 1→3),写审核日志。
|
||||||
|
func (s *ContentService) OutfitAudit(ctx context.Context, adminID int64, outfitUUID string, request *dto.OutfitAuditRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitAudit")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.OutfitNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if outfit.AuditStatus != OutfitAuditPending {
|
||||||
|
return errcode.OutfitAuditInvalid.WithDetails("仅待审核状态的穿搭可审核")
|
||||||
|
}
|
||||||
|
|
||||||
|
auditResult := AuditResultPass
|
||||||
|
reason := ""
|
||||||
|
if request.Result == AuditResultReject {
|
||||||
|
auditResult = AuditResultReject
|
||||||
|
reason = strings.TrimSpace(request.Reason)
|
||||||
|
if reason == "" {
|
||||||
|
reason = "未通过审核"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log := &mysqlmodel.OutfitAuditLog{
|
||||||
|
AuditTime: nowUnix(),
|
||||||
|
}
|
||||||
|
log.CreatedBy = adminID
|
||||||
|
if err := s.outfitDao.OutfitAuditWithLog(ctx, outfit.ID, auditResult, reason, log); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitBan 封禁穿搭(status → 2 封禁)。
|
||||||
|
func (s *ContentService) OutfitBan(ctx context.Context, adminID int64, outfitUUID string, request *dto.OutfitBanRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitBan")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.OutfitNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if outfit.Status == OutfitStatusBanned {
|
||||||
|
return nil // 幂等。
|
||||||
|
}
|
||||||
|
if err := s.outfitDao.OutfitUpdateByID(ctx, outfit.ID, map[string]any{
|
||||||
|
"status": OutfitStatusBanned,
|
||||||
|
"updated_by": adminID,
|
||||||
|
}); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// OutfitUnban 解封穿搭(status → 1 正常)。
|
||||||
|
func (s *ContentService) OutfitUnban(ctx context.Context, adminID int64, outfitUUID string) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOutfitUnban")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
outfit, err := s.outfitDao.OutfitGetByUUID(ctx, outfitUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.OutfitNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if outfit.Status != OutfitStatusBanned {
|
||||||
|
return nil // 幂等。
|
||||||
|
}
|
||||||
|
if err := s.outfitDao.OutfitUpdateByID(ctx, outfit.ID, map[string]any{
|
||||||
|
"status": OutfitStatusNormal,
|
||||||
|
"updated_by": adminID,
|
||||||
|
}); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesList 分页查询衣物。
|
||||||
|
func (s *ContentService) ClothesList(ctx context.Context, request *dto.ClothesListRequest) (*dto.ClothesListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminClothesList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.clothesDao.ClothesList(ctx, mysqldao.ClothesListParams{
|
||||||
|
UserID: request.UserID,
|
||||||
|
ImageProcessStatus: request.ImageProcessStatus,
|
||||||
|
CategoryMain: strings.TrimSpace(request.CategoryMain),
|
||||||
|
Keyword: strings.TrimSpace(request.Keyword),
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClothesItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildClothesItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.ClothesListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClothesDetail 衣物详情。
|
||||||
|
func (s *ContentService) ClothesDetail(ctx context.Context, clothesUUID string) (*dto.ClothesItem, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminClothesDetail")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
clothes, err := s.clothesDao.ClothesGetByUUID(ctx, clothesUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return nil, errcode.ClothesNotFound
|
||||||
|
}
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
item := buildClothesItem(clothes)
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareList 分页查询衣柜分享。
|
||||||
|
func (s *ContentService) ShareList(ctx context.Context, request *dto.ClosetShareListRequest) (*dto.ClosetShareListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.shareDao.ClosetShareList(ctx, mysqldao.ClosetShareListParams{
|
||||||
|
UserID: request.UserID,
|
||||||
|
Status: request.Status,
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClosetShareItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildShareItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.ClosetShareListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareBan 封禁分享(status → 4 封禁)。
|
||||||
|
func (s *ContentService) ShareBan(ctx context.Context, adminID int64, shareUUID string) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareBan")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.ShareNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if share.Status == ShareStatusBanned {
|
||||||
|
return nil // 幂等。
|
||||||
|
}
|
||||||
|
if err := s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusBanned); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShareUnban 解封分享(status → 1 正常)。
|
||||||
|
func (s *ContentService) ShareUnban(ctx context.Context, adminID int64, shareUUID string) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminShareUnban")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
share, err := s.shareDao.ClosetShareGetByUUID(ctx, shareUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.ShareNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if share.Status != ShareStatusBanned {
|
||||||
|
return nil // 幂等。
|
||||||
|
}
|
||||||
|
if err := s.shareDao.ClosetShareUpdateStatus(ctx, shareUUID, ShareStatusNormal); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOutfitListItem 穿搭模型 → 列表项。
|
||||||
|
func buildOutfitListItem(o *mysqlmodel.Outfit) dto.OutfitListItem {
|
||||||
|
return dto.OutfitListItem{
|
||||||
|
ID: o.ID,
|
||||||
|
OutfitUUID: o.OutfitUUID,
|
||||||
|
UserID: o.UserID,
|
||||||
|
OutfitName: o.OutfitName,
|
||||||
|
OutfitDesc: o.OutfitDesc,
|
||||||
|
Scene: o.Scene,
|
||||||
|
Source: o.Source,
|
||||||
|
IsPublic: o.IsPublic,
|
||||||
|
AuditStatus: o.AuditStatus,
|
||||||
|
Status: o.Status,
|
||||||
|
CreatedAt: o.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildClothesItem 衣物模型 → 响应。
|
||||||
|
func buildClothesItem(c *mysqlmodel.Clothes) dto.ClothesItem {
|
||||||
|
return dto.ClothesItem{
|
||||||
|
ID: c.ID,
|
||||||
|
ClothesUUID: c.ClothesUUID,
|
||||||
|
UserID: c.UserID,
|
||||||
|
Name: c.Name,
|
||||||
|
CategoryMain: c.CategoryMain,
|
||||||
|
CategorySub: c.CategorySub,
|
||||||
|
Style: c.Style,
|
||||||
|
ColorMain: c.ColorMain,
|
||||||
|
ColorSub: c.ColorSub,
|
||||||
|
Thickness: c.Thickness,
|
||||||
|
Version: c.Version,
|
||||||
|
Material: c.Material,
|
||||||
|
Pattern: c.Pattern,
|
||||||
|
SleeveType: c.SleeveType,
|
||||||
|
ClothesType: c.ClothesType,
|
||||||
|
NecklineType: c.NecklineType,
|
||||||
|
Formality: c.Formality,
|
||||||
|
Occasions: c.Occasions,
|
||||||
|
VisualImpact: c.VisualImpact,
|
||||||
|
Remark: c.Remark,
|
||||||
|
ProcessedImage: cos.GetCosUrl(c.ProcessedImage),
|
||||||
|
ImageSize: c.ImageSize,
|
||||||
|
ImageProcessStatus: c.ImageProcessStatus,
|
||||||
|
FailReason: c.FailReason,
|
||||||
|
CreatedAt: c.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildShareItem 分享模型 → 响应。
|
||||||
|
func buildShareItem(s *mysqlmodel.ClosetShare) dto.ClosetShareItem {
|
||||||
|
return dto.ClosetShareItem{
|
||||||
|
ID: s.ID,
|
||||||
|
ShareUUID: s.ShareUUID,
|
||||||
|
UserID: s.UserID,
|
||||||
|
ShareType: s.ShareType,
|
||||||
|
ExpireTime: s.ExpireTime,
|
||||||
|
Status: s.Status,
|
||||||
|
CreatedAt: s.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DashboardService 仪表盘统计服务。
|
||||||
|
type DashboardService struct {
|
||||||
|
statsDao *mysqldao.StatsDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDashboardService 创建仪表盘服务。
|
||||||
|
func NewDashboardService() *DashboardService {
|
||||||
|
return &DashboardService{statsDao: mysqldao.NewStatsDao()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary 聚合仪表盘核心指标:用户/订单/营收/待办。
|
||||||
|
func (s *DashboardService) Summary(ctx context.Context) (*dto.DashboardResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.DashboardSummary")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
todayStart := todayStartUnix()
|
||||||
|
|
||||||
|
userStats, err := s.statsDao.UserStatsGet(ctx, todayStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
userStatusMap, err := s.statsDao.UserStatsGetByStatus(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
orderStats, err := s.statsDao.OrderStatsGet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
revenueStats, err := s.statsDao.RevenueStatsGet(ctx, todayStart)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
todoStats, err := s.statsDao.TodoStatsGet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
contentStats, err := s.statsDao.ContentStatsGet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
now := nowUnix()
|
||||||
|
vipStats, err := s.statsDao.VIPStatsGet(ctx, now, now+7*86400)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
goodsStats, err := s.statsDao.GoodsStatsGet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
usageStats, err := s.statsDao.UsageStatsGet(ctx, todayStatDate())
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
socialStats, err := s.statsDao.SocialStatsGet(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.DashboardResponse{
|
||||||
|
Users: dto.DashboardUsers{
|
||||||
|
Total: userStats.Total,
|
||||||
|
TodayNew: userStats.TodayNew,
|
||||||
|
Normal: userStatusMap[UserStatusNormal],
|
||||||
|
TempBan: userStatusMap[UserStatusTempBan],
|
||||||
|
PermBan: userStatusMap[UserStatusPermBan],
|
||||||
|
Deactivated: userStatusMap[UserStatusDeactivated],
|
||||||
|
},
|
||||||
|
Orders: dto.DashboardOrders{
|
||||||
|
Total: orderStats.Total,
|
||||||
|
Pending: orderStats.Pending,
|
||||||
|
Paid: orderStats.Paid,
|
||||||
|
Cancelled: orderStats.Cancelled,
|
||||||
|
Expired: orderStats.Expired,
|
||||||
|
Refunded: orderStats.Refunded,
|
||||||
|
},
|
||||||
|
Revenue: dto.DashboardRevenue{
|
||||||
|
TotalRevenue: revenueStats.TotalRevenue,
|
||||||
|
TodayRevenue: revenueStats.TodayRevenue,
|
||||||
|
RefundTotal: revenueStats.RefundTotal,
|
||||||
|
},
|
||||||
|
Todo: dto.DashboardTodo{
|
||||||
|
RefundPending: todoStats.RefundPending,
|
||||||
|
OutfitPendingAudit: todoStats.OutfitPendingAudit,
|
||||||
|
GoodsOnShelf: todoStats.GoodsOnShelf,
|
||||||
|
},
|
||||||
|
Content: dto.DashboardContent{
|
||||||
|
Outfit: dto.DashboardOutfitStats{
|
||||||
|
Total: contentStats.OutfitTotal,
|
||||||
|
Pending: contentStats.OutfitPending,
|
||||||
|
Passed: contentStats.OutfitPassed,
|
||||||
|
Rejected: contentStats.OutfitRejected,
|
||||||
|
Banned: contentStats.OutfitBanned,
|
||||||
|
},
|
||||||
|
Clothes: dto.DashboardClothesStats{
|
||||||
|
Total: contentStats.ClothesTotal,
|
||||||
|
Processing: contentStats.ClothesProcessing,
|
||||||
|
Success: contentStats.ClothesSuccess,
|
||||||
|
Failed: contentStats.ClothesFailed,
|
||||||
|
},
|
||||||
|
Share: dto.DashboardShareStats{
|
||||||
|
Total: contentStats.ShareTotal,
|
||||||
|
Normal: contentStats.ShareNormal,
|
||||||
|
Cancelled: contentStats.ShareCancelled,
|
||||||
|
Expired: contentStats.ShareExpired,
|
||||||
|
Banned: contentStats.ShareBanned,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
VIP: dto.DashboardVIP{
|
||||||
|
MemberTotal: vipStats.MemberTotal,
|
||||||
|
Monthly: vipStats.Monthly,
|
||||||
|
Yearly: vipStats.Yearly,
|
||||||
|
Trial: vipStats.Trial,
|
||||||
|
ExpiringSoon: vipStats.ExpiringSoon,
|
||||||
|
},
|
||||||
|
Goods: dto.DashboardGoods{
|
||||||
|
Total: goodsStats.Total,
|
||||||
|
OnShelf: goodsStats.OnShelf,
|
||||||
|
OffShelf: goodsStats.OffShelf,
|
||||||
|
ByType: buildGoodsTypeCounts(goodsStats.ByType),
|
||||||
|
},
|
||||||
|
Usage: dto.DashboardUsage{
|
||||||
|
AIRecommend: usageStats.AIRecommend,
|
||||||
|
ClosetShare: usageStats.ClosetShare,
|
||||||
|
OutfitShare: usageStats.OutfitShare,
|
||||||
|
},
|
||||||
|
Social: dto.DashboardSocial{
|
||||||
|
CosImageCount: socialStats.CosImageCount,
|
||||||
|
LikeCount: socialStats.LikeCount,
|
||||||
|
CollectCount: socialStats.CollectCount,
|
||||||
|
},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGoodsTypeCounts 商品类型数量 DAO → DTO。
|
||||||
|
func buildGoodsTypeCounts(in []mysqldao.GoodsTypeCount) []dto.GoodsTypeCount {
|
||||||
|
out := make([]dto.GoodsTypeCount, 0, len(in))
|
||||||
|
for _, v := range in {
|
||||||
|
out = append(out, dto.GoodsTypeCount{Type: v.Type, Count: v.Count})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trends 返回近 N 天趋势(新增用户/已支付订单量/营收,按天补零对齐)。
|
||||||
|
func (s *DashboardService) Trends(ctx context.Context, days int) (*dto.TrendResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.DashboardTrends")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if days <= 0 {
|
||||||
|
days = 30
|
||||||
|
}
|
||||||
|
if days > 90 {
|
||||||
|
days = 90
|
||||||
|
}
|
||||||
|
|
||||||
|
startTs := todayStartUnix() - int64(days-1)*86400
|
||||||
|
stats, err := s.statsDao.TrendStatsGet(ctx, startTs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
loc := time.Now().Location()
|
||||||
|
startDay := time.Unix(startTs, 0).In(loc)
|
||||||
|
points := make([]dto.TrendPoint, 0, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
date := startDay.AddDate(0, 0, i).Format("2006-01-02")
|
||||||
|
points = append(points, dto.TrendPoint{
|
||||||
|
Date: date,
|
||||||
|
NewUsers: stats.NewUsers[date],
|
||||||
|
Orders: stats.Orders[date],
|
||||||
|
Revenue: stats.Revenue[date],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.TrendResponse{Points: points}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoodsService 商品管理服务。
|
||||||
|
type GoodsService struct {
|
||||||
|
goodsDao *mysqldao.GoodsDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGoodsService 创建商品服务。
|
||||||
|
func NewGoodsService() *GoodsService {
|
||||||
|
return &GoodsService{goodsDao: mysqldao.NewGoodsDao()}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询商品(管理端含下架商品)。
|
||||||
|
func (s *GoodsService) List(ctx context.Context, request *dto.GoodsListRequest) (*dto.GoodsListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.goodsDao.GoodsList(ctx, mysqldao.GoodsListParams{
|
||||||
|
GoodsType: request.GoodsType,
|
||||||
|
Status: request.Status,
|
||||||
|
Keyword: strings.TrimSpace(request.Keyword),
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.GoodsItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildGoodsItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.GoodsListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建商品。
|
||||||
|
func (s *GoodsService) Create(ctx context.Context, adminID int64, request *dto.GoodsCreateRequest) (*dto.GoodsItem, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsCreate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
code := strings.TrimSpace(request.GoodsCode)
|
||||||
|
if code == "" {
|
||||||
|
return nil, errcode.InvalidParams.WithDetails("商品编码不能为空")
|
||||||
|
}
|
||||||
|
if request.DiscountPrice > request.Price {
|
||||||
|
return nil, errcode.GoodsPriceInvalid.WithDetails("售价不能高于原价")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编码唯一性校验(含软删,防历史编码占用)。
|
||||||
|
if _, err := s.goodsDao.GoodsGetByCode(ctx, code); err == nil {
|
||||||
|
return nil, errcode.GoodsCodeExists
|
||||||
|
} else if !isNotFound(err) {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
status := request.Status
|
||||||
|
if status == 0 {
|
||||||
|
status = GoodsStatusOffShelf // 新建默认下架,运营确认后再上架。
|
||||||
|
}
|
||||||
|
|
||||||
|
goods := &mysqlmodel.Goods{
|
||||||
|
GoodsCode: code,
|
||||||
|
GoodsName: strings.TrimSpace(request.GoodsName),
|
||||||
|
GoodsType: request.GoodsType,
|
||||||
|
Price: request.Price,
|
||||||
|
DiscountPrice: request.DiscountPrice,
|
||||||
|
MemberDays: request.MemberDays,
|
||||||
|
CosQuota: request.UploadQuota,
|
||||||
|
AIRecommendQuota: request.AIRecommendQuota,
|
||||||
|
ClosetShareQuota: request.ClosetShareQuota,
|
||||||
|
OutfitShareQuota: request.OutfitShareQuota,
|
||||||
|
ValidDays: request.ValidDays,
|
||||||
|
Description: request.Description,
|
||||||
|
Status: status,
|
||||||
|
}
|
||||||
|
goods.CreatedBy = adminID
|
||||||
|
goods.UpdatedBy = adminID
|
||||||
|
if err := s.goodsDao.GoodsCreate(ctx, goods); err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
item := buildGoodsItem(goods)
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新商品(传什么改什么;不修改编码与类型)。
|
||||||
|
func (s *GoodsService) Update(ctx context.Context, adminID int64, id int64, request *dto.GoodsUpdateRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsUpdate")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.GoodsNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{"updated_by": adminID}
|
||||||
|
if request.GoodsName != nil {
|
||||||
|
updates["goods_name"] = strings.TrimSpace(*request.GoodsName)
|
||||||
|
}
|
||||||
|
if request.Price != nil {
|
||||||
|
updates["price"] = *request.Price
|
||||||
|
}
|
||||||
|
if request.DiscountPrice != nil {
|
||||||
|
updates["discount_price"] = *request.DiscountPrice
|
||||||
|
}
|
||||||
|
if request.MemberDays != nil {
|
||||||
|
updates["member_days"] = *request.MemberDays
|
||||||
|
}
|
||||||
|
if request.UploadQuota != nil {
|
||||||
|
updates["cos_quota"] = *request.UploadQuota
|
||||||
|
}
|
||||||
|
if request.AIRecommendQuota != nil {
|
||||||
|
updates["ai_recommend_quota"] = *request.AIRecommendQuota
|
||||||
|
}
|
||||||
|
if request.ClosetShareQuota != nil {
|
||||||
|
updates["closet_share_quota"] = *request.ClosetShareQuota
|
||||||
|
}
|
||||||
|
if request.OutfitShareQuota != nil {
|
||||||
|
updates["outfit_share_quota"] = *request.OutfitShareQuota
|
||||||
|
}
|
||||||
|
if request.ValidDays != nil {
|
||||||
|
updates["valid_days"] = *request.ValidDays
|
||||||
|
}
|
||||||
|
if request.Description != nil {
|
||||||
|
updates["description"] = *request.Description
|
||||||
|
}
|
||||||
|
if len(updates) == 1 {
|
||||||
|
return nil // 无可更新字段。
|
||||||
|
}
|
||||||
|
if err := s.goodsDao.GoodsUpdate(ctx, id, updates); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// UpdateStatus 商品上下架。
|
||||||
|
func (s *GoodsService) UpdateStatus(ctx context.Context, adminID int64, id int64, request *dto.GoodsStatusRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsUpdateStatus")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.GoodsNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if err := s.goodsDao.GoodsUpdate(ctx, id, map[string]any{"status": request.Status, "updated_by": adminID}); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 软删除商品(历史订单已快照商品信息,删除不影响历史订单)。
|
||||||
|
func (s *GoodsService) Delete(ctx context.Context, adminID int64, id int64) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminGoodsDelete")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if _, err := s.goodsDao.GoodsGetByID(ctx, id); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.GoodsNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if err := s.goodsDao.GoodsSoftDelete(ctx, id); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildGoodsItem 商品模型 → 响应(注意 dto.UploadQuota 对应 model.CosQuota)。
|
||||||
|
func buildGoodsItem(g *mysqlmodel.Goods) dto.GoodsItem {
|
||||||
|
return dto.GoodsItem{
|
||||||
|
ID: g.ID,
|
||||||
|
GoodsCode: g.GoodsCode,
|
||||||
|
GoodsName: g.GoodsName,
|
||||||
|
GoodsType: g.GoodsType,
|
||||||
|
Price: g.Price,
|
||||||
|
DiscountPrice: g.DiscountPrice,
|
||||||
|
MemberDays: g.MemberDays,
|
||||||
|
UploadQuota: g.CosQuota,
|
||||||
|
AIRecommendQuota: g.AIRecommendQuota,
|
||||||
|
ClosetShareQuota: g.ClosetShareQuota,
|
||||||
|
OutfitShareQuota: g.OutfitShareQuota,
|
||||||
|
ValidDays: g.ValidDays,
|
||||||
|
Description: g.Description,
|
||||||
|
Status: g.Status,
|
||||||
|
ProductID: g.ProductID,
|
||||||
|
ProductStatus: g.ProductStatus,
|
||||||
|
CreatedAt: g.CreatedAt,
|
||||||
|
UpdatedAt: g.UpdatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redisdao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// broadcastBatchSize 全量广播每批处理的用户数(分批遍历用户 + 分批插入,避免大事务与内存暴涨)。
|
||||||
|
const broadcastBatchSize = 500
|
||||||
|
|
||||||
|
// NotificationService 通知推送服务。
|
||||||
|
type NotificationService struct {
|
||||||
|
notificationDao *mysqldao.NotificationDao
|
||||||
|
userDao *mysqldao.UserDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewNotificationService 创建通知服务。
|
||||||
|
func NewNotificationService() *NotificationService {
|
||||||
|
return &NotificationService{
|
||||||
|
notificationDao: mysqldao.NewNotificationDao(),
|
||||||
|
userDao: mysqldao.NewUserDao(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send 向单个用户发送系统通知(写 user_notifications + Redis 未读计数自增)。
|
||||||
|
func (s *NotificationService) Send(ctx context.Context, adminID int64, request *dto.NotificationSendRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminNotificationSend")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
// 校验接收用户存在。
|
||||||
|
if _, err := s.userDao.UserGetByID(ctx, request.UserID); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
notification := buildSystemNotification(request.UserID, request.Title, request.Content)
|
||||||
|
if err := s.notificationDao.NotificationBatchCreate(ctx, []mysqlmodel.UserNotification{notification}); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
s.incrUnread(ctx, request.UserID)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast 全量广播系统通知:分批遍历用户写入,返回发送人数。
|
||||||
|
func (s *NotificationService) Broadcast(ctx context.Context, adminID int64, request *dto.NotificationBroadcastRequest) (*dto.NotificationBroadcastResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminNotificationBroadcast")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
title := strings.TrimSpace(request.Title)
|
||||||
|
content := strings.TrimSpace(request.Content)
|
||||||
|
|
||||||
|
var sent int64
|
||||||
|
var lastID int64
|
||||||
|
for {
|
||||||
|
ids, err := s.userDao.UserIDListAll(ctx, lastID, broadcastBatchSize)
|
||||||
|
if err != nil {
|
||||||
|
slog.ErrorContext(ctx, "broadcast fetch user ids failed",
|
||||||
|
"sent", sent, "error", err)
|
||||||
|
return &dto.NotificationBroadcastResponse{SentCount: sent}, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if len(ids) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
batch := make([]mysqlmodel.UserNotification, 0, len(ids))
|
||||||
|
for _, uid := range ids {
|
||||||
|
batch = append(batch, buildSystemNotification(uid, title, content))
|
||||||
|
}
|
||||||
|
if err := s.notificationDao.NotificationBatchCreate(ctx, batch); err != nil {
|
||||||
|
slog.ErrorContext(ctx, "broadcast insert batch failed",
|
||||||
|
"sent", sent, "error", err)
|
||||||
|
return &dto.NotificationBroadcastResponse{SentCount: sent}, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
sent += int64(len(ids))
|
||||||
|
|
||||||
|
// best-effort 未读计数自增。
|
||||||
|
for _, uid := range ids {
|
||||||
|
s.incrUnread(ctx, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
lastID = ids[len(ids)-1]
|
||||||
|
if len(ids) < broadcastBatchSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &dto.NotificationBroadcastResponse{SentCount: sent}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// incrUnread 未读计数自增(best-effort,失败仅告警,不影响主流程)。
|
||||||
|
func (s *NotificationService) incrUnread(ctx context.Context, userID int64) {
|
||||||
|
if _, err := redisdao.NotificationUnreadIncr(ctx, userID); err != nil {
|
||||||
|
slog.WarnContext(ctx, "incr notification unread failed", "user_id", userID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildSystemNotification 构造系统通知记录(msg_type=3、sender_id=0、未读)。
|
||||||
|
func buildSystemNotification(userID int64, title, content string) mysqlmodel.UserNotification {
|
||||||
|
return mysqlmodel.UserNotification{
|
||||||
|
NotificationUUID: genUUID(),
|
||||||
|
UserID: userID,
|
||||||
|
MsgType: NotificationTypeSystem,
|
||||||
|
SenderID: NotificationSenderSystem,
|
||||||
|
Title: strings.TrimSpace(title),
|
||||||
|
Content: strings.TrimSpace(content),
|
||||||
|
IsRead: NotificationUnread,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OrderService 订单与退款管理服务。
|
||||||
|
type OrderService struct {
|
||||||
|
orderDao *mysqldao.OrderDao
|
||||||
|
refundDao *mysqldao.RefundDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOrderService 创建订单服务。
|
||||||
|
func NewOrderService() *OrderService {
|
||||||
|
return &OrderService{
|
||||||
|
orderDao: mysqldao.NewOrderDao(),
|
||||||
|
refundDao: mysqldao.NewRefundDao(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询订单。
|
||||||
|
func (s *OrderService) List(ctx context.Context, request *dto.OrderListRequest) (*dto.OrderListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOrderList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.orderDao.OrderList(ctx, mysqldao.OrderListParams{
|
||||||
|
OrderNo: strings.TrimSpace(request.OrderNo),
|
||||||
|
UserID: request.UserID,
|
||||||
|
Status: request.Status,
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.OrderItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildOrderItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.OrderListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 订单详情。
|
||||||
|
func (s *OrderService) Get(ctx context.Context, orderNo string) (*dto.OrderItem, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminOrderGet")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
order, err := s.orderDao.OrderGetByOrderNo(ctx, orderNo)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return nil, errcode.OrderNotFound
|
||||||
|
}
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
item := buildOrderItem(order)
|
||||||
|
return &item, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundList 分页查询退款单。
|
||||||
|
func (s *OrderService) RefundList(ctx context.Context, request *dto.RefundListRequest) (*dto.RefundListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
// status 未传时 query 绑定为 nil,与「待处理=0」天然区分。
|
||||||
|
withStatus := request.Status != nil
|
||||||
|
var status int8
|
||||||
|
if withStatus {
|
||||||
|
status = *request.Status
|
||||||
|
}
|
||||||
|
|
||||||
|
list, total, err := s.refundDao.RefundList(ctx, mysqldao.RefundListParams{
|
||||||
|
Status: status,
|
||||||
|
WithStatus: withStatus,
|
||||||
|
OrderNo: strings.TrimSpace(request.OrderNo),
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.RefundItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildRefundItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.RefundListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundApprove 审核通过:待处理(0) → 退款中(1)。
|
||||||
|
// 实际打款由运营在微信商户平台人工完成,业务服务退款回调负责后续「成功(2)」流转与权益回收。
|
||||||
|
func (s *OrderService) RefundApprove(ctx context.Context, adminID int64, refundNo string, request *dto.RefundAuditRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundApprove")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if _, err := s.refundDao.RefundGetByRefundNo(ctx, refundNo); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.RefundNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.refundDao.RefundTransition(ctx, refundNo, RefundStatusPending, RefundStatusProcessing, "")
|
||||||
|
if err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errcode.RefundStatusInvalid.WithDetails("仅待处理状态的退款单可审核")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundReject 审核驳回:待处理(0) → 失败(3),记录驳回原因。
|
||||||
|
func (s *OrderService) RefundReject(ctx context.Context, adminID int64, refundNo string, request *dto.RefundAuditRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminRefundReject")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
if _, err := s.refundDao.RefundGetByRefundNo(ctx, refundNo); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.RefundNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
ok, err := s.refundDao.RefundTransition(ctx, refundNo, RefundStatusPending, RefundStatusFailed, strings.TrimSpace(request.Reason))
|
||||||
|
if err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
return errcode.RefundStatusInvalid.WithDetails("仅待处理状态的退款单可审核")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOrderItem 订单模型 → 响应。
|
||||||
|
func buildOrderItem(o *mysqlmodel.Order) dto.OrderItem {
|
||||||
|
return dto.OrderItem{
|
||||||
|
OrderNo: o.OrderNo,
|
||||||
|
UserID: o.UserID,
|
||||||
|
GoodsID: o.GoodsID,
|
||||||
|
GoodsName: o.GoodsName,
|
||||||
|
UnitPrice: o.UnitPrice,
|
||||||
|
TotalAmount: o.TotalAmount,
|
||||||
|
PayAmount: o.PayAmount,
|
||||||
|
PayChannel: o.PayChannel,
|
||||||
|
OrderStatus: o.OrderStatus,
|
||||||
|
TransactionID: o.TransactionID,
|
||||||
|
MemberDays: o.MemberDays,
|
||||||
|
UploadQuota: o.CosQuota,
|
||||||
|
AIRecommendQuota: o.AIRecommendQuota,
|
||||||
|
ClosetShareQuota: o.ClosetShareQuota,
|
||||||
|
OutfitShareQuota: o.OutfitShareQuota,
|
||||||
|
ExpireTime: o.ExpireTime,
|
||||||
|
PayTime: o.PayTime,
|
||||||
|
RefundTime: o.RefundTime,
|
||||||
|
RefundAmount: o.RefundAmount,
|
||||||
|
Remark: o.Remark,
|
||||||
|
XPayProductID: o.XPayProductID,
|
||||||
|
XPayPlatform: o.XPayPlatform,
|
||||||
|
XPayEnv: o.XPayEnv,
|
||||||
|
CreatedAt: o.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRefundItem 退款单模型 → 响应。
|
||||||
|
func buildRefundItem(r *mysqlmodel.RefundFlowLog) dto.RefundItem {
|
||||||
|
return dto.RefundItem{
|
||||||
|
RefundNo: r.RefundNo,
|
||||||
|
OrderNo: r.OrderNo,
|
||||||
|
UserID: r.UserID,
|
||||||
|
RefundAmount: r.RefundAmount,
|
||||||
|
RefundReason: r.RefundReason,
|
||||||
|
Status: r.Status,
|
||||||
|
ApplyTime: r.ApplyTime,
|
||||||
|
SuccessTime: r.SuccessTime,
|
||||||
|
FailReason: r.FailReason,
|
||||||
|
CreatedAt: r.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
// Package service 承载管理后台业务逻辑层,编排 dto、mysqldao、redisdao 与 pkg 能力。
|
||||||
|
//
|
||||||
|
// 约定:
|
||||||
|
// - 每个业务域一个 Service 结构体,NewXxxService() 构造,内部持有所需 DAO。
|
||||||
|
// - 方法统一返回 (*dto.XxxResponse, *errcode.Error),将底层 error 映射为语义化错误。
|
||||||
|
// - redis 未命中返回 redis.Nil,按缓存未命中处理,不视为错误。
|
||||||
|
// - 所有管理写操作在 service 层做权限与状态校验(AuthZ)。
|
||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/middleware"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// isNotFound 判断 gorm 是否为「记录不存在」。
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
// isCacheMiss 判断 redis 是否为「未命中」。
|
||||||
|
func isCacheMiss(err error) bool {
|
||||||
|
return errors.Is(err, redis.Nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 分页默认值。
|
||||||
|
const (
|
||||||
|
defaultPage = 1
|
||||||
|
defaultPageSize = 10
|
||||||
|
maxPageSize = 50
|
||||||
|
)
|
||||||
|
|
||||||
|
// normalizePage 规整分页参数,返回可直接用于 DAO 的 page/pageSize。
|
||||||
|
func normalizePage(page, pageSize int) (int, int) {
|
||||||
|
if page <= 0 {
|
||||||
|
page = defaultPage
|
||||||
|
}
|
||||||
|
if pageSize <= 0 {
|
||||||
|
pageSize = defaultPageSize
|
||||||
|
}
|
||||||
|
if pageSize > maxPageSize {
|
||||||
|
pageSize = maxPageSize
|
||||||
|
}
|
||||||
|
return page, pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// nowUnix 当前秒级时间戳。
|
||||||
|
func nowUnix() int64 {
|
||||||
|
return time.Now().Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// todayStartUnix 返回本地时区今日 0 点的秒级时间戳。
|
||||||
|
func todayStartUnix() int64 {
|
||||||
|
now := time.Now()
|
||||||
|
start := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||||
|
return start.Unix()
|
||||||
|
}
|
||||||
|
|
||||||
|
// todayStatDate 返回本地时区今日的 YYYYMMDD 整数(user_daily_usages 统计日期)。
|
||||||
|
func todayStatDate() int64 {
|
||||||
|
now := time.Now()
|
||||||
|
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
|
||||||
|
}
|
||||||
|
|
||||||
|
// genUUID 生成新的 UUID 字符串。
|
||||||
|
func genUUID() string {
|
||||||
|
return uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireAdminID 从 context 取管理员 ID(由中间件注入),缺失返回未授权错误。
|
||||||
|
func requireAdminID(ctx context.Context) (int64, *errcode.Error) {
|
||||||
|
adminID, ok := middleware.GetAdminID(ctx)
|
||||||
|
if !ok || adminID <= 0 {
|
||||||
|
return 0, errcode.Unauthorized
|
||||||
|
}
|
||||||
|
return adminID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
package service
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/dto"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqldao"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/cos"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserService 用户管理服务。
|
||||||
|
type UserService struct {
|
||||||
|
userDao *mysqldao.UserDao
|
||||||
|
quotaGrantDao *mysqldao.QuotaGrantDao
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserService 创建用户管理服务。
|
||||||
|
func NewUserService() *UserService {
|
||||||
|
return &UserService{
|
||||||
|
userDao: mysqldao.NewUserDao(),
|
||||||
|
quotaGrantDao: mysqldao.NewQuotaGrantDao(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 分页查询用户(关键词/状态筛选)。
|
||||||
|
func (s *UserService) List(ctx context.Context, request *dto.UserListRequest) (*dto.UserListResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserList")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
page, pageSize := normalizePage(request.Page, request.PageSize)
|
||||||
|
|
||||||
|
list, total, err := s.userDao.UserList(ctx, mysqldao.UserListParams{
|
||||||
|
Keyword: strings.TrimSpace(request.Keyword),
|
||||||
|
Status: request.Status,
|
||||||
|
Page: page,
|
||||||
|
Size: pageSize,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.UserListItem, 0, len(list))
|
||||||
|
for i := range list {
|
||||||
|
items = append(items, buildUserListItem(&list[i]))
|
||||||
|
}
|
||||||
|
return &dto.UserListResponse{
|
||||||
|
PageResponse: dto.PageResponse{Total: total, Page: page, PageSize: pageSize},
|
||||||
|
List: items,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Detail 用户详情:完整展示永久额度(Cos)与每日额度(AI/社区/衣柜)+ 额度凭证列表。
|
||||||
|
func (s *UserService) Detail(ctx context.Context, userUUID string) (*dto.UserDetailResponse, *errcode.Error) {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserDetail")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return nil, errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowUnix()
|
||||||
|
grants, err := s.quotaGrantDao.QuotaGrantListByUserID(ctx, user.ID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
// 有效凭证按类型聚合(expire_time=0 跟随 VIP)。
|
||||||
|
validAmount := map[int8]int64{}
|
||||||
|
validUsed := map[int8]int64{}
|
||||||
|
for _, g := range grants {
|
||||||
|
if grantExpired(g.ExpireTime, user.VIPExpire, now) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
validAmount[g.QuotaType] += g.Amount
|
||||||
|
validUsed[g.QuotaType] += g.Used
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cos 永久已用(user_quotas.used 永久累计)。
|
||||||
|
cosFreeUsed := int64(0)
|
||||||
|
if q, qerr := s.quotaGrantDao.QuotaGetByUserIDAndType(ctx, user.ID, QuotaTypeCos); qerr == nil {
|
||||||
|
cosFreeUsed = q.Used
|
||||||
|
}
|
||||||
|
|
||||||
|
// 当日已用(user_daily_usages,AI/社区/衣柜)。
|
||||||
|
dailyUsed := map[int8]int64{}
|
||||||
|
if usage, uerr := s.quotaGrantDao.UserDailyUsageGetByUserIDAndDate(ctx, user.ID, todayStatDate()); uerr == nil {
|
||||||
|
dailyUsed[QuotaTypeAI] = usage.AIRecommendCount
|
||||||
|
dailyUsed[QuotaTypeOutfitShare] = usage.OutfitShareCount
|
||||||
|
dailyUsed[QuotaTypeClosetShare] = usage.ClosetShareCount
|
||||||
|
}
|
||||||
|
|
||||||
|
types := []int8{QuotaTypeCos, QuotaTypeAI, QuotaTypeOutfitShare, QuotaTypeClosetShare}
|
||||||
|
quotaItems := make([]dto.QuotaItem, 0, len(types))
|
||||||
|
for _, quotaType := range types {
|
||||||
|
freeTotal := defaultQuotaValue(quotaType)
|
||||||
|
total := freeTotal + validAmount[quotaType]
|
||||||
|
|
||||||
|
var used int64
|
||||||
|
if quotaType == QuotaTypeCos {
|
||||||
|
used = cosFreeUsed + validUsed[quotaType]
|
||||||
|
} else {
|
||||||
|
used = dailyUsed[quotaType]
|
||||||
|
}
|
||||||
|
remain := total - used
|
||||||
|
if remain < 0 {
|
||||||
|
remain = 0
|
||||||
|
}
|
||||||
|
quotaItems = append(quotaItems, dto.QuotaItem{
|
||||||
|
QuotaType: quotaType,
|
||||||
|
Total: total,
|
||||||
|
Used: used,
|
||||||
|
Remain: remain,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
grantItems := make([]dto.UserGrantItem, 0, len(grants))
|
||||||
|
for _, g := range grants {
|
||||||
|
grantItems = append(grantItems, dto.UserGrantItem{
|
||||||
|
ID: g.ID,
|
||||||
|
QuotaType: g.QuotaType,
|
||||||
|
Amount: g.Amount,
|
||||||
|
Used: g.Used,
|
||||||
|
Source: g.Source,
|
||||||
|
ExpireTime: g.ExpireTime,
|
||||||
|
RefID: g.RefID,
|
||||||
|
CreatedAt: g.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
item := buildUserListItem(user)
|
||||||
|
return &dto.UserDetailResponse{UserListItem: item, Quotas: quotaItems, Grants: grantItems}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ban 封禁用户:临时(按天数)/永久,写封禁原因与时间。
|
||||||
|
func (s *UserService) Ban(ctx context.Context, adminID int64, userUUID string, request *dto.UserBanRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserBan")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowUnix()
|
||||||
|
updates := map[string]any{
|
||||||
|
"ban_reason": strings.TrimSpace(request.Reason),
|
||||||
|
"ban_start": now,
|
||||||
|
"updated_by": adminID,
|
||||||
|
}
|
||||||
|
switch request.BanType {
|
||||||
|
case UserStatusTempBan:
|
||||||
|
if request.BanDays <= 0 {
|
||||||
|
return errcode.InvalidParams.WithDetails("临时禁用需填写封禁天数")
|
||||||
|
}
|
||||||
|
updates["status"] = UserStatusTempBan
|
||||||
|
updates["ban_expire"] = now + int64(request.BanDays)*86400
|
||||||
|
case UserStatusPermBan:
|
||||||
|
updates["status"] = UserStatusPermBan
|
||||||
|
updates["ban_expire"] = 0
|
||||||
|
default:
|
||||||
|
return errcode.InvalidParams.WithDetails("封禁类型不合法")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unban 解封用户(恢复正常,清空封禁信息)。
|
||||||
|
func (s *UserService) Unban(ctx context.Context, adminID int64, userUUID string, request *dto.UserUnbanRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserUnban")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
if user.Status != UserStatusTempBan && user.Status != UserStatusPermBan {
|
||||||
|
return nil // 幂等:非封禁状态无需处理。
|
||||||
|
}
|
||||||
|
|
||||||
|
updates := map[string]any{
|
||||||
|
"status": UserStatusNormal,
|
||||||
|
"ban_reason": "",
|
||||||
|
"ban_start": 0,
|
||||||
|
"ban_expire": 0,
|
||||||
|
"updated_by": adminID,
|
||||||
|
}
|
||||||
|
if err := s.userDao.UserUpdateByID(ctx, user.ID, updates); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaAdjust 额度发放:写入有有效期额度凭证(source=加量包)。
|
||||||
|
// Cos 为一次性库存,AI/社区/衣柜 为有效期内每日加量;expire_days 天内有效。
|
||||||
|
func (s *UserService) QuotaAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserQuotaAdjustRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserQuotaAdjust")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
grant := &mysqlmodel.QuotaGrant{
|
||||||
|
UserID: user.ID,
|
||||||
|
QuotaType: request.QuotaType,
|
||||||
|
Amount: request.Amount,
|
||||||
|
Used: 0,
|
||||||
|
Source: QuotaGrantSourceAddon,
|
||||||
|
ExpireTime: nowUnix() + request.ExpireDays*86400,
|
||||||
|
RefID: "manual_" + genUUID(),
|
||||||
|
}
|
||||||
|
grant.CreatedBy = adminID
|
||||||
|
grant.UpdatedBy = adminID
|
||||||
|
|
||||||
|
if err := s.quotaGrantDao.QuotaGrantCreate(ctx, grant); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantRevoke 回收额度凭证(软删除,按 id + user_id 归属校验防越权)。
|
||||||
|
func (s *UserService) GrantRevoke(ctx context.Context, adminID int64, userUUID string, grantID int64) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserGrantRevoke")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := s.quotaGrantDao.QuotaGrantGetByID(ctx, grantID); err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserGrantNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.quotaGrantDao.QuotaGrantSoftDelete(ctx, grantID, user.ID); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// VIPAdjust 人工调整用户会员(事务:更新会员字段 + 人工调整流水)。
|
||||||
|
// 叠加语义(与购买发放一致):vip_expire = max(当前到期, now) + vip_days*86400;vip_days=0 表示取消会员。
|
||||||
|
func (s *UserService) VIPAdjust(ctx context.Context, adminID int64, userUUID string, request *dto.UserVIPAdjustRequest) *errcode.Error {
|
||||||
|
ctx, span := global.Tracer.Start(ctx, "svc.AdminUserVIPAdjust")
|
||||||
|
defer span.End()
|
||||||
|
|
||||||
|
user, err := s.userDao.UserGetByUUID(ctx, userUUID)
|
||||||
|
if err != nil {
|
||||||
|
if isNotFound(err) {
|
||||||
|
return errcode.UserNotFound
|
||||||
|
}
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
|
||||||
|
now := nowUnix()
|
||||||
|
|
||||||
|
// 变更前快照(流水用)。
|
||||||
|
oldType := user.VIPType
|
||||||
|
oldExpire := user.VIPExpire
|
||||||
|
// 会员已过期时接口视角归一为非会员。
|
||||||
|
if oldExpire > 0 && oldExpire < now {
|
||||||
|
oldType = VIPTypeNone
|
||||||
|
}
|
||||||
|
|
||||||
|
var newType int8
|
||||||
|
var newExpire int64
|
||||||
|
if request.VIPDays <= 0 {
|
||||||
|
// 取消会员。
|
||||||
|
if request.VIPType != VIPTypeNone {
|
||||||
|
return errcode.InvalidParams.WithDetails("取消会员时 vip_type 应为 1")
|
||||||
|
}
|
||||||
|
newType = VIPTypeNone
|
||||||
|
newExpire = 0
|
||||||
|
} else {
|
||||||
|
if request.VIPType == VIPTypeNone {
|
||||||
|
return errcode.InvalidParams.WithDetails("赠送会员天数时 vip_type 不能为 1")
|
||||||
|
}
|
||||||
|
newType = request.VIPType
|
||||||
|
base := user.VIPExpire
|
||||||
|
if base < now {
|
||||||
|
base = now
|
||||||
|
}
|
||||||
|
newExpire = base + request.VIPDays*86400
|
||||||
|
}
|
||||||
|
|
||||||
|
flow := &mysqlmodel.VIPFlowLog{
|
||||||
|
UserID: user.ID,
|
||||||
|
ChangeType: VIPChangeTypeManual,
|
||||||
|
OldVIPType: oldType,
|
||||||
|
NewVIPType: newType,
|
||||||
|
OldVIPExpire: oldExpire,
|
||||||
|
Source: VIPSourceManual,
|
||||||
|
Reason: strings.TrimSpace(request.Reason),
|
||||||
|
}
|
||||||
|
// 会员已过期/从未开通时重置开始时间;续期保留原开始时间。
|
||||||
|
if newExpire > 0 && (oldExpire <= 0 || oldExpire <= now) {
|
||||||
|
user.VIPStart = now
|
||||||
|
}
|
||||||
|
user.VIPType = newType
|
||||||
|
user.VIPExpire = newExpire
|
||||||
|
if err := s.userDao.VIPAdjustWithFlow(ctx, user, flow); err != nil {
|
||||||
|
return errcode.DatabaseError
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// defaultQuotaValue 返回某类额度的免费底值:
|
||||||
|
// 优先取 global.QuotaSetting 配置(与业务服务 UserDefaultQuota 免费值对齐),
|
||||||
|
// 未配置(指针为 nil)或负数时回落内置常量 DefaultQuota*。
|
||||||
|
func defaultQuotaValue(quotaType int8) int64 {
|
||||||
|
cfg := global.QuotaSetting
|
||||||
|
switch quotaType {
|
||||||
|
case QuotaTypeCos:
|
||||||
|
if cfg != nil && cfg.Cos != nil && *cfg.Cos >= 0 {
|
||||||
|
return *cfg.Cos
|
||||||
|
}
|
||||||
|
return DefaultQuotaCos
|
||||||
|
case QuotaTypeAI:
|
||||||
|
if cfg != nil && cfg.AI != nil && *cfg.AI >= 0 {
|
||||||
|
return *cfg.AI
|
||||||
|
}
|
||||||
|
return DefaultQuotaAI
|
||||||
|
case QuotaTypeOutfitShare:
|
||||||
|
if cfg != nil && cfg.OutfitShare != nil && *cfg.OutfitShare >= 0 {
|
||||||
|
return *cfg.OutfitShare
|
||||||
|
}
|
||||||
|
return DefaultQuotaOutfitShare
|
||||||
|
case QuotaTypeClosetShare:
|
||||||
|
if cfg != nil && cfg.ClosetShare != nil && *cfg.ClosetShare >= 0 {
|
||||||
|
return *cfg.ClosetShare
|
||||||
|
}
|
||||||
|
return DefaultQuotaClosetShare
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantExpired 判断凭证是否失效:expire_time=0 表示跟随 VIP,其余按到期时间判断。
|
||||||
|
func grantExpired(expireTime, vipExpire, now int64) bool {
|
||||||
|
if expireTime == 0 {
|
||||||
|
return vipExpire <= now
|
||||||
|
}
|
||||||
|
return expireTime <= now
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildUserListItem 用户模型 → 列表项(会员已过期归一为非会员)。
|
||||||
|
func buildUserListItem(u *mysqlmodel.User) dto.UserListItem {
|
||||||
|
vipType := u.VIPType
|
||||||
|
vipExpire := u.VIPExpire
|
||||||
|
if vipType != VIPTypeNone && vipExpire > 0 && vipExpire < nowUnix() {
|
||||||
|
vipType = VIPTypeNone
|
||||||
|
vipExpire = 0
|
||||||
|
}
|
||||||
|
return dto.UserListItem{
|
||||||
|
ID: u.ID,
|
||||||
|
UserUUID: u.UserUUID,
|
||||||
|
Username: u.Username,
|
||||||
|
Avatar: toImageURL(u.Avatar),
|
||||||
|
Sex: u.Sex,
|
||||||
|
Phone: u.Phone,
|
||||||
|
Status: u.Status,
|
||||||
|
BanReason: u.BanReason,
|
||||||
|
BanStart: u.BanStart,
|
||||||
|
BanExpire: u.BanExpire,
|
||||||
|
VIPType: vipType,
|
||||||
|
VIPExpire: vipExpire,
|
||||||
|
LastLoginTime: u.LastLoginTime,
|
||||||
|
CreatedAt: u.CreatedAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toImageURL 头像地址兼容转换:以 http(s) 开头的历史完整 URL 直接透传,
|
||||||
|
// 其余(COS 相对 key)转为带 CDN 鉴权签名的可访问 URL。
|
||||||
|
func toImageURL(key string) string {
|
||||||
|
if key == "" || strings.HasPrefix(key, "http://") || strings.HasPrefix(key, "https://") {
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
return cos.GetCosUrl(key)
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/mysqlmodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/redismodel"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/routers"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/internal/service"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/tracing"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"go.opentelemetry.io/contrib/bridges/otelslog"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"gopkg.in/natefinch/lumberjack.v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
if err := setupSetting(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTel 需在 logger 之前初始化,使 LoggerProvider 就绪后 slog 可桥接到 OTel Logs。
|
||||||
|
setupOTel()
|
||||||
|
|
||||||
|
if err := setupSlogLogger(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setupMysqlEngine(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := setupRedisEngine(); err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
gin.SetMode(global.ServerSetting.RunMode)
|
||||||
|
|
||||||
|
// 建管理员表并幂等创建初始超级管理员(依赖 MySQL 已就绪,故在 main 中执行)。
|
||||||
|
migrateAdminTables()
|
||||||
|
service.EnsureInitialSuperAdmin(context.Background())
|
||||||
|
|
||||||
|
router := routers.NewRouter()
|
||||||
|
|
||||||
|
s := &http.Server{
|
||||||
|
Addr: ":" + global.ServerSetting.HttpPort,
|
||||||
|
Handler: router,
|
||||||
|
ReadTimeout: time.Duration(global.ServerSetting.ReadTimeout) * time.Second,
|
||||||
|
WriteTimeout: time.Duration(global.ServerSetting.WriteTimeout) * time.Second,
|
||||||
|
|
||||||
|
// 限制http请求头的大小为1MB
|
||||||
|
MaxHeaderBytes: 1 << 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后台启动 HTTP 服务;启动失败(如端口占用)通过 channel 反馈主流程,避免进程「假存活」。
|
||||||
|
serverErr := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
if err := s.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
serverErr <- err
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
slog.Info("server started", "port", global.ServerSetting.HttpPort)
|
||||||
|
|
||||||
|
// 等待退出信号或服务启动错误。
|
||||||
|
quit := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-quit:
|
||||||
|
slog.Info("shutting down server...")
|
||||||
|
case err := <-serverErr:
|
||||||
|
slog.Error("http server start failed", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优雅关闭:HTTP 与 OTel 使用独立 context,避免 HTTP 等待耗尽 OTel flush 的超时。
|
||||||
|
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer shutdownCancel()
|
||||||
|
if err := s.Shutdown(shutdownCtx); err != nil {
|
||||||
|
slog.Error("http server shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if global.OTelShutdown != nil {
|
||||||
|
otelCtx, otelCancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer otelCancel()
|
||||||
|
if err := global.OTelShutdown(otelCtx); err != nil {
|
||||||
|
slog.Error("otel shutdown error", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slog.Info("server exited")
|
||||||
|
}
|
||||||
|
|
||||||
|
// migrateAdminTables 仅迁移管理后台自建表(admin_users)。
|
||||||
|
func migrateAdminTables() {
|
||||||
|
if err := mysqlmodel.AutoMigrateAdminTables(global.MysqlEngine); err != nil {
|
||||||
|
slog.Error("auto migrate admin tables failed", "error", err)
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSetting() error {
|
||||||
|
setting, err := setting.NewSetting()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Server", &global.ServerSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Log", &global.LogSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Mysql", &global.MysqlSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Redis", &global.RedisSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Otel", &global.OtelSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("JWT", &global.JWTSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 校验 JWT 关键配置,防止密钥缺失导致可用空密钥伪造 token。
|
||||||
|
if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" {
|
||||||
|
return fmt.Errorf("JWT secret key must not be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Admin", &global.AdminSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Quota", &global.QuotaSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = setting.ReadSection("Cos", &global.CosSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 Server 配置(时间字段已按秒解析,此处仅做存在性校验)。
|
||||||
|
if global.ServerSetting == nil {
|
||||||
|
return fmt.Errorf("Server config section is missing")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupSlogLogger() error {
|
||||||
|
// 确保日志目录存在(lumberjack 不会自动创建父目录,目录缺失时文件日志会静默失效)。
|
||||||
|
if err := os.MkdirAll(global.LogSetting.LogSavePath, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create log dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
writer := &lumberjack.Logger{
|
||||||
|
Filename: global.LogSetting.LogSavePath + "/" + global.LogSetting.LogFileName + global.LogSetting.LogFileExt,
|
||||||
|
MaxSize: global.LogSetting.MaxSize,
|
||||||
|
MaxBackups: global.LogSetting.MaxBackups,
|
||||||
|
MaxAge: global.LogSetting.MaxAge,
|
||||||
|
Compress: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
multiWriter := io.MultiWriter(writer, os.Stdout)
|
||||||
|
|
||||||
|
level := slog.LevelInfo
|
||||||
|
switch global.LogSetting.LogLevel {
|
||||||
|
case "Debug":
|
||||||
|
level = slog.LevelDebug
|
||||||
|
case "Warn":
|
||||||
|
level = slog.LevelWarn
|
||||||
|
case "Error":
|
||||||
|
level = slog.LevelError
|
||||||
|
default:
|
||||||
|
level = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// 本地 JSON 日志(文件 + stdout),并注入 trace_id/span_id。
|
||||||
|
localHandler := tracing.OtelContextHandler{
|
||||||
|
Handler: slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{
|
||||||
|
AddSource: true,
|
||||||
|
Level: level,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
|
// OTel 日志桥接:将 slog 记录导出到 OTel Logs(LoggerProvider 已在 setupOTel 就绪;
|
||||||
|
// 未启用 OTel 时为 no-op,不产生开销)。
|
||||||
|
otelHandler := otelslog.NewHandler("zt-closet-manage-backend")
|
||||||
|
|
||||||
|
// 扇出:本地日志 + OTel 日志同时输出。
|
||||||
|
slog.SetDefault(slog.New(tracing.NewFanoutHandler(localHandler, otelHandler)))
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupMysqlEngine 初始化 MySQL 数据库连接。
|
||||||
|
func setupMysqlEngine() error {
|
||||||
|
var err error
|
||||||
|
global.MysqlEngine, err = mysqlmodel.NewMysqlEngine(global.MysqlSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupRedisEngine 初始化 Redis 连接。
|
||||||
|
func setupRedisEngine() error {
|
||||||
|
var err error
|
||||||
|
global.RedisEngine, err = redismodel.NewRedisEngine(global.RedisSetting)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupOTel() {
|
||||||
|
tracer, shutdown, err := tracing.NewOTelProvider(global.OtelSetting)
|
||||||
|
// NewOTelProvider 在任何错误路径都会返回可用的 no-op tracer 与安全 shutdown。
|
||||||
|
global.Tracer = tracer
|
||||||
|
global.OTelShutdown = shutdown
|
||||||
|
if err != nil {
|
||||||
|
// 此时 logger 尚未就绪,用标准输出告警;已降级为 no-op。
|
||||||
|
fmt.Printf("[WARN] init otel failed, downgraded to no-op: %v\n", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
name := "zt-closet-manage-backend"
|
||||||
|
if global.OtelSetting != nil && global.OtelSetting.ServiceName != "" {
|
||||||
|
name = global.OtelSetting.ServiceName
|
||||||
|
}
|
||||||
|
global.Meter = otel.Meter(name)
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/errcode"
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Response 统一响应包装
|
||||||
|
type Response struct {
|
||||||
|
Ctx *gin.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewResponse(c *gin.Context) *Response {
|
||||||
|
return &Response{Ctx: c}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToResponse 成功响应
|
||||||
|
func (r *Response) ToResponse(data interface{}) {
|
||||||
|
if data == nil {
|
||||||
|
data = gin.H{}
|
||||||
|
}
|
||||||
|
r.Ctx.JSON(http.StatusOK, gin.H{
|
||||||
|
"code": errcode.Success.Code(),
|
||||||
|
"msg": errcode.Success.Msg(),
|
||||||
|
"data": data,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ToErrorResponse 错误响应
|
||||||
|
func (r *Response) ToErrorResponse(err *errcode.Error) {
|
||||||
|
response := gin.H{
|
||||||
|
"code": err.Code(),
|
||||||
|
"msg": err.Msg(),
|
||||||
|
}
|
||||||
|
if details := err.Details(); len(details) > 0 {
|
||||||
|
response["details"] = details
|
||||||
|
}
|
||||||
|
r.Ctx.JSON(err.StatusCode(), response)
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
// Package cos 提供 COS 下载 URL 生成(CDN 鉴权签名)。
|
||||||
|
package cos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/md5"
|
||||||
|
"fmt"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GetCosUrl 将 COS 相对 key 转为带 CDN 鉴权签名的完整 URL(复刻业务服务 upload.GetCosUrl)。
|
||||||
|
//
|
||||||
|
// 签名规则:sign = md5(CdnDownloadSecretKey + "/" + fileName + ts)。
|
||||||
|
// 保护:fileName 为空、Cos 配置缺失或下载域名为空时,原样返回 fileName,避免未配置时 panic 或生成无效 URL。
|
||||||
|
func GetCosUrl(fileName string) string {
|
||||||
|
if fileName == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if global.CosSetting == nil || global.CosSetting.CosDownloadDomain == "" {
|
||||||
|
return fileName
|
||||||
|
}
|
||||||
|
|
||||||
|
ts := time.Now().Unix()
|
||||||
|
raw := global.CosSetting.CdnDownloadSecretKey + "/" + fileName + strconv.FormatInt(ts, 10)
|
||||||
|
sign := fmt.Sprintf("%x", md5.Sum([]byte(raw)))
|
||||||
|
|
||||||
|
return fmt.Sprintf("https://%s/%s?sign=%s&t=%d",
|
||||||
|
global.CosSetting.CosDownloadDomain,
|
||||||
|
fileName,
|
||||||
|
sign,
|
||||||
|
ts,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
// 管理后台业务错误码段位:15000 - 21999(独立进程,与业务服务码值无冲突)
|
||||||
|
//
|
||||||
|
// 细分:
|
||||||
|
// 15000 - 15999 管理员域(登录/账号)
|
||||||
|
// 16000 - 16999 用户管理域
|
||||||
|
// 17000 - 17999 商品域
|
||||||
|
// 18000 - 18999 订单/退款域
|
||||||
|
// 20000 - 20999 内容审核域(穿搭/衣物/分享)
|
||||||
|
// 21000 - 21999 通知域
|
||||||
|
|
||||||
|
// ===== 管理员域(15000 - 15999) =====
|
||||||
|
var (
|
||||||
|
AdminLoginFailed = NewError(15000, "账号或密码错误")
|
||||||
|
AdminDisabled = NewError(15001, "管理员账号已被禁用")
|
||||||
|
AdminNotFound = NewError(15002, "管理员不存在")
|
||||||
|
AdminUsernameExists = NewError(15003, "登录账号已存在")
|
||||||
|
AdminPasswordInvalid = NewError(15004, "密码格式不正确")
|
||||||
|
AdminOldPasswordWrong = NewError(15005, "原密码错误")
|
||||||
|
AdminCannotDeleteSelf = NewError(15006, "不能删除自己")
|
||||||
|
AdminCannotDisableSelf = NewError(15007, "不能禁用自己")
|
||||||
|
AdminCannotModifySuper = NewError(15008, "不能操作超级管理员账号")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 用户管理域(16000 - 16999) =====
|
||||||
|
var (
|
||||||
|
UserNotFound = NewError(16000, "用户不存在")
|
||||||
|
UserQuotaNotFound = NewError(16001, "用户额度记录不存在")
|
||||||
|
UserQuotaInvalid = NewError(16002, "调整后的总配额不能小于已使用量")
|
||||||
|
UserVIPInvalid = NewError(16003, "VIP 参数不合法")
|
||||||
|
UserGrantNotFound = NewError(16004, "额度凭证不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 商品域(17000 - 17999) =====
|
||||||
|
var (
|
||||||
|
GoodsNotFound = NewError(17000, "商品不存在")
|
||||||
|
GoodsCodeExists = NewError(17001, "商品编码已存在")
|
||||||
|
GoodsPriceInvalid = NewError(17002, "商品价格不合法")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 订单/退款域(18000 - 18999) =====
|
||||||
|
var (
|
||||||
|
OrderNotFound = NewError(18000, "订单不存在")
|
||||||
|
RefundNotFound = NewError(18001, "退款单不存在")
|
||||||
|
RefundStatusInvalid = NewError(18002, "当前退款状态不允许该操作")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 内容审核域(20000 - 20999) =====
|
||||||
|
var (
|
||||||
|
OutfitNotFound = NewError(20000, "穿搭不存在")
|
||||||
|
OutfitAuditInvalid = NewError(20001, "当前状态不允许审核")
|
||||||
|
ClothesNotFound = NewError(20002, "衣物不存在")
|
||||||
|
ShareNotFound = NewError(20003, "分享不存在")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 通知域(21000 - 21999) =====
|
||||||
|
var (
|
||||||
|
NotificationSendFailed = NewError(21000, "通知发送失败")
|
||||||
|
)
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
// 公共错误码段位:10000 - 19999
|
||||||
|
//
|
||||||
|
// 细分:
|
||||||
|
// 10000 - 10999 通用 / 参数错误
|
||||||
|
// 11000 - 11999 鉴权 / Token
|
||||||
|
// 12000 - 12999 权限 / 资源
|
||||||
|
// 19000 - 19999 系统级错误(DB、缓存、RPC)
|
||||||
|
|
||||||
|
// 成功
|
||||||
|
var Success = NewError(0, "成功")
|
||||||
|
|
||||||
|
// ===== 通用错误(10000 - 10999) =====
|
||||||
|
var (
|
||||||
|
InvalidParams = NewError(10000, "请求参数错误")
|
||||||
|
NotFound = NewError(10001, "资源不存在")
|
||||||
|
MethodNotAllow = NewError(10002, "请求方法不允许")
|
||||||
|
TooManyRequests = NewError(10003, "请求过于频繁")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 鉴权 / Token(11000 - 11999) =====
|
||||||
|
var (
|
||||||
|
Unauthorized = NewError(11000, "未授权")
|
||||||
|
UnauthorizedTokenError = NewError(11001, "Token 鉴权失败")
|
||||||
|
UnauthorizedTokenTimeout = NewError(11002, "Token 已过期")
|
||||||
|
UnauthorizedTokenGenerate = NewError(11003, "Token 生成失败")
|
||||||
|
UnauthorizedTokenMissing = NewError(11004, "Token 缺失")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 权限(12000 - 12999) =====
|
||||||
|
var (
|
||||||
|
Forbidden = NewError(12000, "无权限访问")
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== 系统错误(19000 - 19999) =====
|
||||||
|
var (
|
||||||
|
ServerError = NewError(19000, "服务内部错误")
|
||||||
|
DatabaseError = NewError(19001, "数据库错误")
|
||||||
|
CacheError = NewError(19002, "缓存错误")
|
||||||
|
RPCError = NewError(19003, "下游服务调用失败")
|
||||||
|
UnknownError = NewError(19999, "未知错误")
|
||||||
|
)
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package errcode
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Error 业务错误
|
||||||
|
type Error struct {
|
||||||
|
code int
|
||||||
|
msg string
|
||||||
|
details []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// codes 用于注册时去重检查
|
||||||
|
var codes = map[int]string{}
|
||||||
|
|
||||||
|
// NewError 注册并创建一个错误码
|
||||||
|
func NewError(code int, msg string) *Error {
|
||||||
|
if _, exists := codes[code]; exists {
|
||||||
|
panic(fmt.Sprintf("error code %d already exists, please change", code))
|
||||||
|
}
|
||||||
|
codes[code] = msg
|
||||||
|
return &Error{code: code, msg: msg}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Error() string {
|
||||||
|
return fmt.Sprintf("error code: %d, msg: %s", e.code, e.msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *Error) Code() int { return e.code }
|
||||||
|
func (e *Error) Msg() string { return e.msg }
|
||||||
|
func (e *Error) Details() []string { return e.details }
|
||||||
|
|
||||||
|
func (e *Error) Msgf(args ...interface{}) string {
|
||||||
|
return fmt.Sprintf(e.msg, args...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithDetails 附加详细信息(返回副本)
|
||||||
|
func (e *Error) WithDetails(details ...string) *Error {
|
||||||
|
newError := *e
|
||||||
|
newError.details = append(newError.details, details...)
|
||||||
|
return &newError
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatusCode 业务码 → HTTP 状态码
|
||||||
|
// 仅对少数特殊错误返回非 200,其余统一 200,由前端按业务 code 处理
|
||||||
|
func (e *Error) StatusCode() int {
|
||||||
|
switch e.code {
|
||||||
|
case Success.Code():
|
||||||
|
return http.StatusOK
|
||||||
|
|
||||||
|
// 鉴权类返回 401,便于前端拦截器统一处理跳转登录
|
||||||
|
case Unauthorized.Code(),
|
||||||
|
UnauthorizedTokenError.Code(),
|
||||||
|
UnauthorizedTokenTimeout.Code(),
|
||||||
|
UnauthorizedTokenMissing.Code():
|
||||||
|
return http.StatusUnauthorized
|
||||||
|
|
||||||
|
// 越权/无权限返回 403,便于网关与审计识别越权事件
|
||||||
|
case Forbidden.Code():
|
||||||
|
return http.StatusForbidden
|
||||||
|
|
||||||
|
// 限流返回 429,便于网关识别限流事件
|
||||||
|
case TooManyRequests.Code():
|
||||||
|
return http.StatusTooManyRequests
|
||||||
|
|
||||||
|
// 系统错误返回 500,便于运维监控告警
|
||||||
|
case ServerError.Code(),
|
||||||
|
DatabaseError.Code(),
|
||||||
|
CacheError.Code(),
|
||||||
|
RPCError.Code(),
|
||||||
|
UnknownError.Code():
|
||||||
|
return http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
return http.StatusOK
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package lzcwyjwt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/global"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
type TokenType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
TokenTypeAccess TokenType = "access"
|
||||||
|
TokenTypeRefresh TokenType = "refresh"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Claims 管理员 JWT claims(与业务服务接口风格一致,字段替换为 AdminID)。
|
||||||
|
type Claims struct {
|
||||||
|
AdminID int64 `json:"admin_id"`
|
||||||
|
TokenType TokenType `json:"token_type"`
|
||||||
|
jwt.RegisteredClaims
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTokenInvalid = errors.New("token invalid")
|
||||||
|
ErrTokenExpired = errors.New("token expired")
|
||||||
|
ErrTokenWrongType = errors.New("token type mismatch")
|
||||||
|
)
|
||||||
|
|
||||||
|
func GenerateTokens(adminID int64) (accessToken, refreshToken string, err error) {
|
||||||
|
now := time.Now()
|
||||||
|
accessClaims := Claims{
|
||||||
|
AdminID: adminID,
|
||||||
|
TokenType: TokenTypeAccess,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: global.JWTSetting.Issuer,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.AccessTokenExpire) * time.Second)),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshClaims := Claims{
|
||||||
|
AdminID: adminID,
|
||||||
|
TokenType: TokenTypeRefresh,
|
||||||
|
RegisteredClaims: jwt.RegisteredClaims{
|
||||||
|
Issuer: global.JWTSetting.Issuer,
|
||||||
|
IssuedAt: jwt.NewNumericDate(now),
|
||||||
|
ExpiresAt: jwt.NewNumericDate(now.Add(time.Duration(global.JWTSetting.RefreshTokenExpire) * time.Second)),
|
||||||
|
NotBefore: jwt.NewNumericDate(now),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
accessToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims).SignedString([]byte(global.JWTSetting.SecretKey))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshToken, err = jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims).SignedString([]byte(global.JWTSetting.SecretKey))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, refreshToken, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseAccessToken(tokenString string) (*Claims, error) {
|
||||||
|
claims, err := parseToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 token 类型(防止 refresh token 被当 access token 用)
|
||||||
|
if claims.TokenType != TokenTypeAccess {
|
||||||
|
return nil, ErrTokenWrongType
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRefreshToken(tokenString string) (*Claims, error) {
|
||||||
|
claims, err := parseToken(tokenString)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验 token 类型(防止 access token 被当 refresh token 用)
|
||||||
|
if claims.TokenType != TokenTypeRefresh {
|
||||||
|
return nil, ErrTokenWrongType
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseToken 解析并校验 token 签名、签发方。
|
||||||
|
func parseToken(tokenString string) (*Claims, error) {
|
||||||
|
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
// 验证签名算法
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
return []byte(global.JWTSetting.SecretKey), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 区分过期和其他错误
|
||||||
|
if errors.Is(err, jwt.ErrTokenExpired) {
|
||||||
|
return nil, ErrTokenExpired
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, ok := token.Claims.(*Claims)
|
||||||
|
if !ok || !token.Valid {
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验签发方,防止跨服务 token 混用。
|
||||||
|
if global.JWTSetting.Issuer != "" && claims.Issuer != global.JWTSetting.Issuer {
|
||||||
|
return nil, ErrTokenInvalid
|
||||||
|
}
|
||||||
|
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。
|
||||||
|
type ServerSettingS struct {
|
||||||
|
Domain string // 服务域名(用于 CORS 允许来源)
|
||||||
|
RunMode string
|
||||||
|
HttpPort string
|
||||||
|
ReadTimeout int // 秒
|
||||||
|
WriteTimeout int // 秒
|
||||||
|
}
|
||||||
|
|
||||||
|
type LogSettingS struct {
|
||||||
|
LogSavePath string
|
||||||
|
LogFileName string
|
||||||
|
LogFileExt string
|
||||||
|
MaxSize int
|
||||||
|
MaxBackups int
|
||||||
|
MaxAge int
|
||||||
|
LogLevel string
|
||||||
|
}
|
||||||
|
|
||||||
|
type MysqlSettingS struct {
|
||||||
|
UserName string
|
||||||
|
Password string
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
DBName string
|
||||||
|
MaxIdleConns int
|
||||||
|
MaxOpenConns int
|
||||||
|
ConnMaxLifeTime int
|
||||||
|
ConnMaxIdleTime int
|
||||||
|
}
|
||||||
|
|
||||||
|
type RedisSettingS struct {
|
||||||
|
Host string
|
||||||
|
Port int
|
||||||
|
Password string
|
||||||
|
DB int
|
||||||
|
PoolSize int
|
||||||
|
MaxIdleConns int
|
||||||
|
MinIdleConns int
|
||||||
|
MaxRetryBackoff int
|
||||||
|
MinRetryBackoff int
|
||||||
|
MaxRetries int
|
||||||
|
DialTimeout int
|
||||||
|
ReadTimeout int
|
||||||
|
WriteTimeout int
|
||||||
|
PoolTimeout int
|
||||||
|
}
|
||||||
|
|
||||||
|
type OtelSettingS struct {
|
||||||
|
Enabled bool
|
||||||
|
ServiceName string
|
||||||
|
ServiceVersion string
|
||||||
|
Environment string
|
||||||
|
ExporterEndpoint string
|
||||||
|
Insecure bool
|
||||||
|
SamplerRatio float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type JWTSettingS struct {
|
||||||
|
SecretKey string
|
||||||
|
Issuer string
|
||||||
|
AccessTokenExpire int // 秒
|
||||||
|
RefreshTokenExpire int // 秒
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminSettingS 管理后台专属配置。
|
||||||
|
type AdminSettingS struct {
|
||||||
|
InitialUsername string // 初始超级管理员账号(服务启动时幂等创建)
|
||||||
|
InitialPassword string // 初始超级管理员密码(明文仅存配置,入库为 bcrypt 哈希)
|
||||||
|
}
|
||||||
|
|
||||||
|
// QuotaSettingS 免费额度底值配置(用于管理后台计算并展示「总额度」)。
|
||||||
|
// 必须与业务服务(zt-closet-backend)的 UserDefaultQuota 免费值保持一致;
|
||||||
|
// 使用指针类型以区分「未配置」与「显式配置为 0」,未配置或负数回落内置默认 20/3/10/5。
|
||||||
|
type QuotaSettingS struct {
|
||||||
|
Cos *int64 // 免费上传额度(永久,不每天重置)
|
||||||
|
AI *int64 // 免费 AI 推荐额度(每日,每天 0 点重置)
|
||||||
|
OutfitShare *int64 // 免费社区分享额度(每日)
|
||||||
|
ClosetShare *int64 // 免费衣柜分享额度(每日)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CosSettingS CDN 下载配置(仅用于把衣物图片/头像的相对 COS key 拼接为带鉴权签名的可访问 URL)。
|
||||||
|
// 管理后台与业务服务共用同一 COS,此处的域名与鉴权密钥须与业务服务 configs/config.yaml 的 Cos 段一致。
|
||||||
|
type CosSettingS struct {
|
||||||
|
CosDownloadDomain string // CDN 下载域名
|
||||||
|
CdnDownloadSecretKey string // CDN 下载鉴权密钥(机密,仅存 config.yaml)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Setting) ReadSection(k string, v any) error {
|
||||||
|
err := s.vp.UnmarshalKey(k, v)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package setting
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/spf13/viper"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Setting 封装 viper 配置读取能力。
|
||||||
|
type Setting struct {
|
||||||
|
vp *viper.Viper
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSetting 初始化配置读取器,仅从 configs/config.yaml 读取配置。
|
||||||
|
// 所有配置项一律在配置文件中直接填写,不支持环境变量覆盖。
|
||||||
|
func NewSetting() (*Setting, error) {
|
||||||
|
vp := viper.New()
|
||||||
|
vp.AddConfigPath("configs/")
|
||||||
|
vp.SetConfigName("config")
|
||||||
|
vp.SetConfigType("yaml")
|
||||||
|
if err := vp.ReadInConfig(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Setting{vp: vp}, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package tracing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"log/slog"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FanoutHandler 是一个将日志记录扇出到多个 slog.Handler 的组合处理器,
|
||||||
|
// 用于同时输出本地 JSON 日志与 OTel 日志桥接。
|
||||||
|
type FanoutHandler struct {
|
||||||
|
handlers []slog.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFanoutHandler 创建扇出处理器(忽略 nil handler)。
|
||||||
|
func NewFanoutHandler(handlers ...slog.Handler) *FanoutHandler {
|
||||||
|
filtered := make([]slog.Handler, 0, len(handlers))
|
||||||
|
for _, h := range handlers {
|
||||||
|
if h != nil {
|
||||||
|
filtered = append(filtered, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &FanoutHandler{handlers: filtered}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enabled 任一子处理器启用即返回 true。
|
||||||
|
func (f *FanoutHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
for _, h := range f.handlers {
|
||||||
|
if h.Enabled(ctx, level) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 将记录分发给每个启用的子处理器(各自使用副本,避免相互污染)。
|
||||||
|
func (f *FanoutHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
var errs []error
|
||||||
|
for _, h := range f.handlers {
|
||||||
|
if h.Enabled(ctx, r.Level) {
|
||||||
|
if err := h.Handle(ctx, r.Clone()); err != nil {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.Join(errs...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithAttrs 为每个子处理器附加属性。
|
||||||
|
func (f *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
next := make([]slog.Handler, len(f.handlers))
|
||||||
|
for i, h := range f.handlers {
|
||||||
|
next[i] = h.WithAttrs(attrs)
|
||||||
|
}
|
||||||
|
return &FanoutHandler{handlers: next}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithGroup 为每个子处理器开启分组。
|
||||||
|
func (f *FanoutHandler) WithGroup(name string) slog.Handler {
|
||||||
|
if name == "" {
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
next := make([]slog.Handler, len(f.handlers))
|
||||||
|
for i, h := range f.handlers {
|
||||||
|
next[i] = h.WithGroup(name)
|
||||||
|
}
|
||||||
|
return &FanoutHandler{handlers: next}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package tracing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log/slog"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
)
|
||||||
|
|
||||||
|
type OtelContextHandler struct {
|
||||||
|
Handler slog.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h OtelContextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||||
|
if span := trace.SpanFromContext(ctx); span.SpanContext().IsValid() {
|
||||||
|
sc := span.SpanContext()
|
||||||
|
r.AddAttrs(
|
||||||
|
slog.String("trace_id", sc.TraceID().String()),
|
||||||
|
slog.String("span_id", sc.SpanID().String()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return h.Handler.Handle(ctx, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h OtelContextHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||||
|
return h.Handler.Enabled(ctx, level)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h OtelContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||||
|
return OtelContextHandler{Handler: h.Handler.WithAttrs(attrs)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h OtelContextHandler) WithGroup(name string) slog.Handler {
|
||||||
|
return OtelContextHandler{Handler: h.Handler.WithGroup(name)}
|
||||||
|
}
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
package tracing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"git.lzcwy.cn/zhengchiliu/zt-closet-manage-backend/pkg/setting"
|
||||||
|
|
||||||
|
"go.opentelemetry.io/contrib/instrumentation/runtime"
|
||||||
|
"go.opentelemetry.io/otel"
|
||||||
|
"go.opentelemetry.io/otel/attribute"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc"
|
||||||
|
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||||
|
logglobal "go.opentelemetry.io/otel/log/global"
|
||||||
|
metricnoop "go.opentelemetry.io/otel/metric/noop"
|
||||||
|
"go.opentelemetry.io/otel/propagation"
|
||||||
|
sdklog "go.opentelemetry.io/otel/sdk/log"
|
||||||
|
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
|
||||||
|
"go.opentelemetry.io/otel/sdk/resource"
|
||||||
|
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||||
|
"go.opentelemetry.io/otel/trace"
|
||||||
|
"go.opentelemetry.io/otel/trace/noop"
|
||||||
|
)
|
||||||
|
|
||||||
|
// defaultServiceName 缺省服务名。
|
||||||
|
const defaultServiceName = "zt-closet-manage-backend"
|
||||||
|
|
||||||
|
// noopShutdown 安全的空关闭函数。
|
||||||
|
func noopShutdown(context.Context) error { return nil }
|
||||||
|
|
||||||
|
// NewOTelProvider 初始化 OpenTelemetry 三大信号(Traces/Metrics/Logs)并通过 OTLP gRPC 导出。
|
||||||
|
//
|
||||||
|
// 返回:
|
||||||
|
// - trace.Tracer:全局 Tracer(供 global.Tracer 使用);
|
||||||
|
// - func(ctx) error:聚合的优雅关闭函数,进程退出时调用以 flush 三个 Provider;
|
||||||
|
// - error:初始化错误。
|
||||||
|
//
|
||||||
|
// 行为约定:
|
||||||
|
// - cfg.Enabled=false:返回 no-op Tracer 与空 Shutdown,不建立任何导出器(业务层 Tracer.Start 仍安全)。
|
||||||
|
// - cfg.Insecure=true:OTLP gRPC 使用非 TLS(内网采集器)。
|
||||||
|
// - 导出器采用惰性连接,采集器不可用时不阻断启动,仅后台重试。
|
||||||
|
func NewOTelProvider(cfg *setting.OtelSettingS) (trace.Tracer, func(context.Context) error, error) {
|
||||||
|
name := defaultServiceName
|
||||||
|
if cfg != nil && cfg.ServiceName != "" {
|
||||||
|
name = cfg.ServiceName
|
||||||
|
}
|
||||||
|
|
||||||
|
// 未启用:降级为 no-op。
|
||||||
|
if cfg == nil || !cfg.Enabled {
|
||||||
|
return noop.NewTracerProvider().Tracer(name), noopShutdown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
res, err := resource.New(ctx, resource.WithAttributes(
|
||||||
|
attribute.String("service.name", name),
|
||||||
|
attribute.String("service.version", valueOr(cfg.ServiceVersion, "unknown")),
|
||||||
|
attribute.String("deployment.environment", valueOr(cfg.Environment, "unknown")),
|
||||||
|
))
|
||||||
|
if err != nil {
|
||||||
|
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel build resource: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
endpoint := cfg.ExporterEndpoint
|
||||||
|
|
||||||
|
// ===== Traces =====
|
||||||
|
traceOpts := []otlptracegrpc.Option{otlptracegrpc.WithEndpoint(endpoint)}
|
||||||
|
if cfg.Insecure {
|
||||||
|
traceOpts = append(traceOpts, otlptracegrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
traceExp, err := otlptracegrpc.New(ctx, traceOpts...)
|
||||||
|
if err != nil {
|
||||||
|
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel trace exporter: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ratio := cfg.SamplerRatio
|
||||||
|
if ratio <= 0 {
|
||||||
|
ratio = 1.0
|
||||||
|
}
|
||||||
|
tp := sdktrace.NewTracerProvider(
|
||||||
|
sdktrace.WithBatcher(traceExp),
|
||||||
|
sdktrace.WithResource(res),
|
||||||
|
sdktrace.WithSampler(sdktrace.ParentBased(sdktrace.TraceIDRatioBased(ratio))),
|
||||||
|
)
|
||||||
|
otel.SetTracerProvider(tp)
|
||||||
|
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||||
|
propagation.TraceContext{},
|
||||||
|
propagation.Baggage{},
|
||||||
|
))
|
||||||
|
|
||||||
|
// ===== Metrics =====
|
||||||
|
metricOpts := []otlpmetricgrpc.Option{otlpmetricgrpc.WithEndpoint(endpoint)}
|
||||||
|
if cfg.Insecure {
|
||||||
|
metricOpts = append(metricOpts, otlpmetricgrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
metricExp, err := otlpmetricgrpc.New(ctx, metricOpts...)
|
||||||
|
if err != nil {
|
||||||
|
// 已建 trace,尽力关闭并恢复全局默认 provider,避免留下已关闭的 provider。
|
||||||
|
_ = tp.Shutdown(ctx)
|
||||||
|
otel.SetTracerProvider(noop.NewTracerProvider())
|
||||||
|
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel metric exporter: %w", err)
|
||||||
|
}
|
||||||
|
mp := sdkmetric.NewMeterProvider(
|
||||||
|
sdkmetric.WithResource(res),
|
||||||
|
sdkmetric.WithReader(sdkmetric.NewPeriodicReader(metricExp)),
|
||||||
|
)
|
||||||
|
otel.SetMeterProvider(mp)
|
||||||
|
|
||||||
|
// Go runtime 指标采集(GC、goroutine、内存等)。
|
||||||
|
if rerr := runtime.Start(runtime.WithMeterProvider(mp)); rerr != nil {
|
||||||
|
// 非致命,仅记录。
|
||||||
|
fmt.Printf("otel runtime metrics start failed: %v\n", rerr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Logs =====
|
||||||
|
logOpts := []otlploggrpc.Option{otlploggrpc.WithEndpoint(endpoint)}
|
||||||
|
if cfg.Insecure {
|
||||||
|
logOpts = append(logOpts, otlploggrpc.WithInsecure())
|
||||||
|
}
|
||||||
|
logExp, err := otlploggrpc.New(ctx, logOpts...)
|
||||||
|
if err != nil {
|
||||||
|
_ = tp.Shutdown(ctx)
|
||||||
|
_ = mp.Shutdown(ctx)
|
||||||
|
otel.SetTracerProvider(noop.NewTracerProvider())
|
||||||
|
otel.SetMeterProvider(metricnoop.NewMeterProvider())
|
||||||
|
return noop.NewTracerProvider().Tracer(name), noopShutdown, fmt.Errorf("otel log exporter: %w", err)
|
||||||
|
}
|
||||||
|
lp := sdklog.NewLoggerProvider(
|
||||||
|
sdklog.WithResource(res),
|
||||||
|
sdklog.WithProcessor(sdklog.NewBatchProcessor(logExp)),
|
||||||
|
)
|
||||||
|
logglobal.SetLoggerProvider(lp)
|
||||||
|
|
||||||
|
// 聚合优雅关闭:依次 flush/关闭三个 Provider。
|
||||||
|
shutdown := func(ctx context.Context) error {
|
||||||
|
return errors.Join(
|
||||||
|
tp.Shutdown(ctx),
|
||||||
|
mp.Shutdown(ctx),
|
||||||
|
lp.Shutdown(ctx),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tp.Tracer(name), shutdown, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueOr 返回 v,v 为空时返回 def。
|
||||||
|
func valueOr(v, def string) string {
|
||||||
|
if v == "" {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user