补齐 HTTP 层:实现用户模块、路由与中间件接线、COS 上传及优雅启动
- 新增用户与上传错误码文件及 Recovery 中间件 - 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入 - 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto - 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装 - 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像 - 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出 - 新增 configs 示例配置、global 配置结构及 go.mod 依赖
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
---
|
||||
name: http-layer-completion
|
||||
overview: 补齐 zt-online-game-backend 的 HTTP 层:修复路由与中间件接线、实现 user 模块(微信登录/刷新 token/我的信息/改资料/上传头像)service 与 COS 上传能力、补 Recovery 中间件与错误码、完成 main.go 启动与优雅退出;不含 Redis、OTel、WebSocket。
|
||||
todos:
|
||||
- id: errcode-recovery
|
||||
content: 新增用户/上传错误码文件与 Recovery 中间件,镜像兄弟项目范式
|
||||
status: completed
|
||||
- id: router-wiring
|
||||
content: 修复 router.go 导入与中间件链并注册 user 路由,补 handler 的 errcode 导入
|
||||
status: completed
|
||||
dependencies:
|
||||
- errcode-recovery
|
||||
- id: dao-model-dto
|
||||
content: 补 UserGetByWechatOpenID 与用户状态常量,按纯 JWT 语义修订刷新令牌 dto
|
||||
status: completed
|
||||
- id: upload-cos
|
||||
content: 移除 upload 包的 OTel Tracer 调用,新增 FileUploadCos 上传封装
|
||||
status: completed
|
||||
- id: user-service
|
||||
content: 用 [subagent:code-explorer] 核对兄弟项目细节后实现 UserService 五个方法与辅助函数
|
||||
status: completed
|
||||
dependencies:
|
||||
- dao-model-dto
|
||||
- upload-cos
|
||||
- errcode-recovery
|
||||
- id: main-boot
|
||||
content: 裁剪 main.go 配置段并去 OTel 桥接,补启动/优雅退出与 lumberjack 依赖
|
||||
status: completed
|
||||
dependencies:
|
||||
- router-wiring
|
||||
- user-service
|
||||
- id: verify-build
|
||||
content: 执行 go mod tidy/build/vet 与启动自检,用 [skill:lsp-code-analysis] 核查无 OTel/Redis/WS 残留
|
||||
status: completed
|
||||
dependencies:
|
||||
- main-boot
|
||||
---
|
||||
|
||||
## 用户需求
|
||||
|
||||
把 zt-online-game-backend 项目现有的 HTTP 层面代码补充完善,使其可编译、可启动并可对外提供用户相关接口。明确排除:Redis、链路追踪/可观测(OTel)、WebSocket 相关代码;users 表由使用者自行准备;刷新令牌采用纯令牌校验换取新令牌(不吊销旧令牌)。
|
||||
|
||||
## 产品概述
|
||||
|
||||
纯后端 JSON 接口服务,无界面:请求经统一中间件链处理,响应统一为「业务码 + 提示 + 数据」结构,错误按业务码区分参数错误、未授权、令牌过期、账号禁用、上传失败等场景;服务启动时自动校验关键配置,运行中 panic 不再导致进程崩溃。
|
||||
|
||||
## 核心功能
|
||||
|
||||
1. 服务启动与生命周期:读取配置并校验必填项(令牌密钥、过期时间等)、初始化日志/数据库/对象存储、启动 HTTP 服务、收到退出信号后优雅关闭;端口、读写超时来自配置
|
||||
2. 健康检查:GET /ping 返回 pong,用于存活探测
|
||||
3. 微信登录:用小程序 code 换取 openid,未注册自动创建账号(随机用户标识、默认昵称、正常状态);并发注册冲突时自动兜底回查;签发 access/refresh 双令牌并返回用户信息
|
||||
4. 刷新令牌:校验 refresh token,确认用户存在且状态正常后重新签发双令牌,返回新的过期时间
|
||||
5. 获取我的信息:返回昵称、头像(统一转为可访问地址)、账号状态、用户标识
|
||||
6. 修改资料:更新昵称/头像地址,含长度校验;无需更新的字段不提交
|
||||
7. 上传头像:校验文件非空、扩展名白名单、大小上限、文件头真实性(防伪造图片),落临时文件后上传对象存储,更新用户头像并返回可访问地址,临时文件必定清理
|
||||
8. 鉴权保护:除健康检查、登录、刷新令牌外,其余接口需携带有效令牌;缺失/格式错误/过期/类型不符均返回明确错误
|
||||
9. 统一异常处理:服务端 panic 统一记录结构化日志(含请求标识与堆栈)并返回 500;客户端断开类 panic 仅记录不误写响应
|
||||
|
||||
## 技术栈
|
||||
|
||||
- 语言/框架:Go 1.26.1、Gin v1.12.0(已是现有依赖)
|
||||
- 数据层:GORM + MySQL 驱动(现有)、gorm.io/plugin/soft_delete(现有)
|
||||
- 配置:spf13/viper(现有,读取 configs/config.yaml)
|
||||
- 鉴权:golang-jwt/jwt/v5(现有)、google/uuid(现有)
|
||||
- 对象存储:腾讯云 cos-go-sdk-v5(现有)
|
||||
- 日志:log/slog + gopkg.in/natefinch/lumberjack.v2 轮转(lumberjack 尚不在 go.mod/go.sum,需 go mod tidy 拉取)
|
||||
- 明确不引入:任何 OTel 依赖、Redis 业务代码、WebSocket 相关代码
|
||||
- 实施参照:同组织兄弟项目 zt-closet-backend(同模板、同目录结构),按需裁剪移植
|
||||
|
||||
## 实现策略与关键决策
|
||||
|
||||
1. 以兄弟项目为同构模板,移植范围仅限:路由注册方式、Recovery 中间件、错误码段位、service 错误映射与日志风格、头像上传链路
|
||||
2. 中间件链固定为 Recovery → RequestID → GinSlog → CORS(删除 GinOTelMiddleware;router.go 的 import 现指向 zt-closet-backend,必须改回本项目)
|
||||
3. 路由路径对齐兄弟项目:公开区 POST /api/v1/user/wxlogin、POST /api/v1/user/refresh-token;鉴权区(JWTAuth)GET /user/info、PUT /user/profile、POST /user/avatar/upload;健康检查 GET /ping
|
||||
4. RefreshToken 纯 JWT 无状态:解析 refresh token → 校验用户存在且状态正常 → 重新签发双令牌;移除 dto 中仅服务于 Redis 吊销的 access_token 字段并改写注释
|
||||
5. ExpireTime 语义与兄弟项目一致,取「当前 Unix 秒 + AccessTokenExpire」的绝对时间戳(同步澄清 dto 注释)
|
||||
6. 错误码段位对齐兄弟项目:用户模块 20000-29999(UserDisabled 21001、UserWxLoginFailed 21002、UserWxCodeInvalid 21003、UserNotFound 22000、UserCreateFailed 29000、UserUpdateFailed 29001);上传模块 30000 段(UploadFileEmpty、UploadFileExtNotAllow、UploadFileTooLarge)+ 39000(UploadFileSaveFailed)。新增码默认走 200 业务码通道,保持现有 StatusCode 设计,不改 errcode.go
|
||||
7. 头像上传链路:空文件/扩展名白名单/大小上限/文件头 magic 校验(仅读文件头不超过 12 字节,避免全量读内存)→ UserGetByID 取 userUUID → FileGenName 随机名 → FileSaveTmp 落盘 → 上传 COS(key 采用 {userUUID}/{userID}/avatar/{随机名},与兄弟项目一致)→ 更新 users.avatar 存 COS key → 返回 CDN 签名 URL;defer 删除临时文件(防循环上传写满磁盘)
|
||||
8. 新用户注册:32 位随机 UUID(uuid 去横线,匹配 varchar(32))、默认昵称「微信用户」、状态正常;openid 唯一键并发冲突(MySQL 1062)时回查已存在用户继续登录
|
||||
9. COS 初始化失败降级:仅告警、CosClient 置空,上传时返回明确错误而非 panic(沿用现有 FileDeleteCos 的 nil 保护约定)
|
||||
10. main.go 现状为兄弟项目半成品粘贴:读取了本项目未声明的配置段(Otel/Tokenhub/MattingModel/ClassificationModel/WechatPay/UserDefaultQuota/NewUserGift/AdReward/WechatVirtualPay),并引用 otelslog/tracing 桥接与 lumberjack;需裁剪为实际存在的 7 个段(Server/Log/Mysql/Redis/JWT/Upload/Cos/WechatMini,Redis 仅读配置不建连接),删除 OTel 桥接,保留用户已写好的 lumberjack + JSON slog 方案
|
||||
11. main 启动流程:init 中依次 setupSetting → setupSlogLogger → setupMysqlEngine → setupCosClient(失败 panic);main 中 gin.SetMode、构建路由、http.Server(ReadTimeout/WriteTimeout 按配置秒、MaxHeaderBytes 1MB)、后台 ListenAndServe + 错误 channel、监听 SIGINT/SIGTERM、5 秒超时优雅关闭
|
||||
|
||||
## 性能与可靠性
|
||||
|
||||
- 上传请求体已在 handler 侧用 MaxBytesReader 限制(MaxSizeMB + 1MB 边界开销),service 侧再校验大小与类型,双层防护
|
||||
- 文件头校验只读取头部字节;上传成功后立即删除临时文件;日志脱敏沿用现有 sanitizeQuery;panic 日志含 request_id 与堆栈,连接断开类 panic 不回写响应
|
||||
- 复杂度不变:登录/刷新为 O(1) 数据库访问 + 令牌签发;头像上传为 O(文件大小) 流式复制
|
||||
|
||||
## 架构设计
|
||||
|
||||
分层保持现状:HTTP 请求 → 中间件链 → /api/v1 路由分组(公开/鉴权)→ handler(参数绑定 + 统一响应)→ service(业务校验与编排,返回 *errcode.Error)→ mysqldao(GORM)→ MySQL;文件经 pkg/upload 校验落盘、pkg/cos 客户端上传至对象存储;配置经 viper 写入 global;不引入 Redis/OTel/WS 任何组件。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
zt-online-game-backend/
|
||||
├── main.go [MODIFY] 裁剪配置段读取(删除未声明段)、移除 OTel 日志桥接,
|
||||
│ 保留 lumberjack+JSON slog 方案;补 init()(配置→日志→MySQL→COS)
|
||||
│ 与 main()(gin 模式、路由、http.Server、错误 channel、信号优雅退出)
|
||||
├── go.mod / go.sum [MODIFY] go mod tidy 引入 lumberjack,整理依赖(移除 main.go 中 OTel 引用后无需新增 OTel 依赖)
|
||||
├── internal/
|
||||
│ ├── middleware/
|
||||
│ │ └── recovery.go [NEW] Recovery 中间件:镜像兄弟项目实现,panic 结构化日志(含 request_id/堆栈)、
|
||||
│ │ broken pipe 判定(连接断开仅记录、Abort),其余返回 500
|
||||
│ ├── routers/
|
||||
│ │ ├── router.go [MODIFY] import 改回本项目、中间件链去掉 GinOTelMiddleware、
|
||||
│ │ │ 注册 /ping 与公开(wxlogin/refresh-token)+ 鉴权(JWTAuth)用户路由
|
||||
│ │ └── api/v1/user.go [MODIFY] 补 pkg/errcode import(当前引用 errcode.UploadFileEmpty 等符号但未导入,编译不过)
|
||||
│ ├── service/user.go [MODIFY] 实现 WxLogin/RefreshToken/InfoGetBySelf/ProfileUpdate/AvatarUpload
|
||||
│ │ 及辅助函数(isNotFound/isDuplicateKey/genUUID/nowUnix),
|
||||
│ │ 错误经 errcode 映射、关键路径 slog 记录
|
||||
│ ├── mysqldao/user.go [MODIFY] 新增 UserGetByWechatOpenID(按 wechat_open_id 查询,登录必需)
|
||||
│ ├── mysqlmodel/user.go [MODIFY] 新增用户状态常量(1 正常/2 临时禁用/3 永久禁用/4 已注销)
|
||||
│ └── dto/user.go [MODIFY] 刷新令牌请求移除仅用于 Redis 吊销的 access_token 字段并改写注释;
|
||||
│ 澄清 ExpireTime 为绝对 Unix 秒时间戳
|
||||
└── pkg/
|
||||
├── errcode/
|
||||
│ ├── user_code.go [NEW] 用户模块错误码(20000-29999 段所选子集:禁用/登录失败/Code 无效/不存在/创建更新失败)
|
||||
│ └── upload_code.go [NEW] 上传错误码(UploadFileEmpty 30000、UploadFileExtNotAllow 30001、
|
||||
│ UploadFileTooLarge 30002、UploadFileSaveFailed 39000)
|
||||
└── upload/upload.go [MODIFY] FileDeleteCos 移除 global.Tracer/span 调用;
|
||||
新增 FileUploadCos(ctx, key, localPath):nil 客户端保护 +
|
||||
Object.Upload(ctx, key, localPath, nil) 上传
|
||||
```
|
||||
|
||||
## 关键契约(供实现对照)
|
||||
|
||||
- service 方法签名与 handler 已声明的调用一致,错误类型为 *errcode.Error,返回 nil 表示成功
|
||||
- 更新用户资料/头像复用现有 DAO 签名 UserUpdateByID(ctx, *mysqlmodel.User),传入仅含待更新字段的对象(GORM Updates 忽略零值,主键不参与更新)
|
||||
- FileUploadCos 内部调用 COS SDK 的 Object.Upload(ctx, key, localPath, nil)(三返回值,取 error)
|
||||
|
||||
## 验证方式
|
||||
|
||||
- 在项目根目录执行 go mod tidy、go build ./...、go vet ./...,确保零编译告警
|
||||
- 若本地 MySQL 与 configs/config.yaml 可用,启动服务验证 /ping 返回 pong,未带令牌访问 /api/v1/user/info 返回 401,带伪造令牌返回令牌错误
|
||||
- 静态核查:全仓无 Tracer/otel/otelslog/tracing/zt-closet-backend 残留引用;无 Redis/WS 新增代码
|
||||
|
||||
## Agent Extensions
|
||||
|
||||
### SubAgent
|
||||
|
||||
- **code-explorer**
|
||||
- Purpose: 在实现 UserService 前,核对兄弟项目 zt-closet-backend 的 isDuplicateKey/genUUID/nowUnix 等辅助函数与 RefreshToken、头像上传的原始实现细节,确保同构移植不失真
|
||||
- Expected outcome: 产出可对照的辅助函数与流程清单,UserService 五个方法按同组织既有范式一次写对
|
||||
|
||||
### Skill
|
||||
|
||||
- **lsp-code-analysis**
|
||||
- Purpose: 在收尾阶段对修改涉及的关键符号做引用级核查(如确认 FileDeleteCos/新 errcode 符号/路由注册的引用面完整,且不再有 OTel/Tracer 残留引用)
|
||||
- Expected outcome: 符号引用一致性报告,确保无悬空引用、无未使用导入、编译与静态检查全绿
|
||||
@@ -0,0 +1 @@
|
||||
configs/config.yaml
|
||||
@@ -0,0 +1,70 @@
|
||||
Server:
|
||||
Domain: test.com #服务域名,用于 CORS 允许来源(http/https 均放行);置 * 或不填则允许所有来源
|
||||
RunMode: release #gin运行模式,debug 和 release
|
||||
HttpPort: 8082
|
||||
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
|
||||
MaxIdleConns: 30
|
||||
MaxOpenConns: 100
|
||||
ConnMaxLifeTime: 3600
|
||||
ConnMaxIdleTime: 600
|
||||
Redis:
|
||||
Host: 127.0.0.1
|
||||
Port: 6379
|
||||
Password: ""
|
||||
DB: 0
|
||||
PoolSize: 20
|
||||
MinIdleConns: 5
|
||||
MaxIdleConns: 10
|
||||
MaxRetryBackoff: 10
|
||||
MinRetryBackoff: 500
|
||||
MaxRetries: 3
|
||||
DialTimeout: 5
|
||||
ReadTimeout: 3
|
||||
WriteTimeout: 3
|
||||
PoolTimeout: 4
|
||||
JWT:
|
||||
SecretKey: replace_with_a_strong_random_secret # 必填,为空则启动失败
|
||||
Issuer: zt-online-game-backend
|
||||
AccessTokenExpire: 7200
|
||||
RefreshTokenExpire: 86400
|
||||
Upload:
|
||||
TmpSavePath: storage/tmp
|
||||
MaxSizeMB: 20
|
||||
AllowedExtensions:
|
||||
- jpg
|
||||
- jpeg
|
||||
- png
|
||||
Cos:
|
||||
CosSecretID: your_cos_secret_id # 腾讯云 COS SecretId
|
||||
CosSecretKey: your_cos_secret_key # 腾讯云 COS SecretKey
|
||||
CosUploadDomain: your_cos_upload_domain # 上传域名
|
||||
CosDownloadDomain: your_cos_download_domain # 下载域名
|
||||
CdnDownloadSecretKey: your_cdn_download_secret_key # CDN 下载鉴权密钥
|
||||
WechatMini:
|
||||
AppID: your_mini_appid
|
||||
AppSecret: your_mini_appsecret
|
||||
WS:
|
||||
PingInterval: 25 # 客户端心跳间隔(秒);服务端读超时取其 2 倍
|
||||
ReadTimeout: 50 # 读超时(秒):超过该时长未收到任何消息即判死断开
|
||||
WriteTimeout: 10 # 单条消息写超时(秒)
|
||||
MaxMessageBytes: 8192 # 单帧上限(字节)
|
||||
MaxMsgPerSec: 20 # 单连接每秒消息数上限,超出断开
|
||||
SendQueueSize: 64 # 每连接发送队列长度,写满视为慢客户端并断开
|
||||
Compression: false # 是否启用 permessage-deflate 压缩
|
||||
MultiDevice: false # false=同账号新连接踢掉旧连接
|
||||
OriginPatterns: [] # 允许的 Origin 域名(浏览器调试用,小游戏连接不带 Origin)
|
||||
@@ -0,0 +1,7 @@
|
||||
package global
|
||||
|
||||
import "github.com/tencentyun/cos-go-sdk-v5"
|
||||
|
||||
var (
|
||||
CosClient *cos.Client
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
package global
|
||||
|
||||
import "gorm.io/gorm"
|
||||
|
||||
var MysqlEngine *gorm.DB
|
||||
@@ -0,0 +1,5 @@
|
||||
package global
|
||||
|
||||
import "github.com/redis/go-redis/v9"
|
||||
|
||||
var RedisEngine *redis.Client
|
||||
@@ -0,0 +1,14 @@
|
||||
package global
|
||||
|
||||
import "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/setting"
|
||||
|
||||
var (
|
||||
ServerSetting *setting.ServerSettingS
|
||||
LogSetting *setting.LogSettingS
|
||||
MysqlSetting *setting.MysqlSettingS
|
||||
RedisSetting *setting.RedisSettingS
|
||||
JWTSetting *setting.JWTSettingS
|
||||
UploadSetting *setting.UploadSettingS
|
||||
CosSetting *setting.CosSettingS
|
||||
WechatMiniSetting *setting.WechatMiniSettingS
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
module git.lzcwy.cn/zhengchiliu/zt-online-game-backend
|
||||
|
||||
go 1.26.1
|
||||
|
||||
require (
|
||||
github.com/gin-gonic/gin v1.12.0
|
||||
github.com/go-sql-driver/mysql v1.10.1
|
||||
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
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75
|
||||
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.4 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.2 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/clbanning/mxj v1.8.4 // indirect
|
||||
github.com/cloudwego/base64x v0.1.7 // indirect
|
||||
github.com/fsnotify/fsnotify v1.10.1 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.15 // indirect
|
||||
github.com/gin-contrib/sse v1.1.2 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.2 // indirect
|
||||
github.com/go-playground/validator/v10 v10.30.5 // 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/google/go-querystring v1.2.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/mitchellh/mapstructure v1.5.0 // indirect
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||
github.com/mozillazg/go-httpheader v0.4.0 // 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.63.0 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // 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.9.1 // indirect
|
||||
go.uber.org/atomic v1.12.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/arch v0.31.0 // indirect
|
||||
golang.org/x/crypto v0.57.0 // indirect
|
||||
golang.org/x/net v0.59.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.42.0 // indirect
|
||||
google.golang.org/protobuf v1.36.12 // indirect
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
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.4 h1:FgtV/4aBHpla9AxuMpuuzVUpa/Cf3izufkxNmnEzdI8=
|
||||
github.com/bytedance/sonic v1.15.4/go.mod h1:8e51yTPdY8M6t+vvGL1c2Y1xL9i+frEeIAQAEl75NUc=
|
||||
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/clbanning/mxj v1.8.4 h1:HuhwZtbyvyOw+3Z1AowPkU87JkJUSv751ELWaiTpj8I=
|
||||
github.com/clbanning/mxj v1.8.4/go.mod h1:BVjHeAH+rl9rs6f+QIpeRl0tfu10SXn1pUSa5PVGJng=
|
||||
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.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
|
||||
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
|
||||
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.2 h1:MU2fgl1RrdYTMcgJLtz2kJF+vPg3xrqaaKfUUU18tCo=
|
||||
github.com/gin-contrib/sse v1.1.2/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-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.2 h1:LCsMLC9RzmbUMNUPVYD15dmcjwYAJhmX8mPZRW4rAVU=
|
||||
github.com/go-playground/universal-translator v0.18.2/go.mod h1:67VZIMp5lQpDWlnStOct22q1bkdJGJqHghbOtmkawxk=
|
||||
github.com/go-playground/validator/v10 v10.30.5 h1:YyCXvVShZbs2Sm3Mb53eNOlhRXctSOzW5QJAouCTZL4=
|
||||
github.com/go-playground/validator/v10 v10.30.5/go.mod h1:wEqiaov48pXX1kjhc3Da8y0M0Dtg/BK7gurFBLgwFrQ=
|
||||
github.com/go-sql-driver/mysql v1.10.1 h1:arlSnNLq6a5yxGxV7qg9lF4j0C+KwD6NbQyKr9QL6ME=
|
||||
github.com/go-sql-driver/mysql v1.10.1/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.2.3/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||
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/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
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/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0=
|
||||
github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/uuid v1.1.1/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
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/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/mitchellh/mapstructure v1.4.3/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
||||
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/mozillazg/go-httpheader v0.2.1/go.mod h1:jJ8xECTlalr6ValeXYdOF8fFUISeBAdw6E61aqQma60=
|
||||
github.com/mozillazg/go-httpheader v0.4.0 h1:aBn6aRXtFzyDLZ4VIRLsZbbJloagQfMnCiYgOq6hK4w=
|
||||
github.com/mozillazg/go-httpheader v0.4.0/go.mod h1:PuT8h0pw6efvp8ZeUec1Rs7dwjK08bt6gKSReGMqtdA=
|
||||
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.63.0 h1:LIFGHI4PFUhhw2dDD1ARHdCff143ffMHwZtbnbuJ78A=
|
||||
github.com/quic-go/quic-go v0.63.0/go.mod h1:RAro2j2yN9a9EiPACLHT9IB2NXCvGQmmo/alT0yYI0w=
|
||||
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.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
github.com/rs/dnscache v0.0.0-20230804202142-fc85eb664529/go.mod h1:qe5TWALJ8/a1Lqznoc5BDHpYX/8HU60Hm2AwRmqzxqA=
|
||||
github.com/sagikazarmark/locafero v0.12.0 h1:/NQhBAkUb4+fH1jivKHWusDYFjMOOKU88eegjfxfHb4=
|
||||
github.com/sagikazarmark/locafero v0.12.0/go.mod h1:sZh36u/YSZ918v0Io+U9ogLYQJ9tLLBmM4eneO6WwsI=
|
||||
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/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.0.563/go.mod h1:7sCQWVkxcsR38nffDW057DRGk8mUjK1Ing/EFOK8s8Y=
|
||||
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/kms v1.0.563/go.mod h1:uom4Nvi9W+Qkom0exYiJ9VWJjXwyxtPYTkKkaLMlfE0=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75 h1:eRCGP5chujSYGFnsCPxlhQcN9wtlSO/4eXrxKtLVAIw=
|
||||
github.com/tencentyun/cos-go-sdk-v5 v0.7.75/go.mod h1:STbTNaNKq03u+gscPEGOahKzLcGSYOj6Dzc5zNay7Pg=
|
||||
github.com/tencentyun/qcloud-cos-sts-sdk v0.0.0-20250515025012-e0eec8a5d123/go.mod h1:b18KQa4IxHbxeseW1GcZox53d7J0z39VNONTxvvlkXw=
|
||||
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.9.1 h1:jewiFs2m1/VOQp8qhFshX6hWZ+EAXDhZHXExAUMcOgQ=
|
||||
go.mongodb.org/mongo-driver/v2 v2.9.1/go.mod h1:SHKN0IWkKmEVGHLjXnni6s4wPKX4v86FTgOeJJFuXcA=
|
||||
go.uber.org/atomic v1.12.0 h1:BvcXdFKuviU4fTL/f+SxdQ5qJX/Jix8pAkgdUcb3XOE=
|
||||
go.uber.org/atomic v1.12.0/go.mod h1:I6c4cg+6HCxRjfjSsYtApoFILnpc0CGUdGkXVqbYVNk=
|
||||
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.31.0 h1:22MlEb14/O/EPCYHFxsDdv5TuLD5dMjT5e2QeJw4ULk=
|
||||
golang.org/x/arch v0.31.0/go.mod h1:KcJSod3cqT2dKcjBxqTyGfbumNikqU9p5tHJinPJnuY=
|
||||
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
|
||||
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
|
||||
golang.org/x/net v0.59.0 h1:5zfYln+w5XCxwrnMMJPufRgNoXEaGxl0wo5GqPXyues=
|
||||
golang.org/x/net v0.59.0/go.mod h1:2DA/G1UfVbCpQPeWTmMPGY7Cs2PkBkwu743bVX5PIVg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
|
||||
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
|
||||
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,54 @@
|
||||
package dto
|
||||
|
||||
// UserWxLoginRequest 微信登录请求
|
||||
type UserWxLoginRequest struct {
|
||||
Code string `json:"code" binding:"required"` // 微信登录code
|
||||
}
|
||||
|
||||
// UserWxLoginResponse 登录响应
|
||||
type UserWxLoginResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(绝对 Unix 秒时间戳)
|
||||
UserInfo UserInfoGetBySelfResponse `json:"user_info"`
|
||||
}
|
||||
|
||||
type UserInfoGetBySelfRequest struct{}
|
||||
|
||||
// UserInfoGetBySelfResponse 用户信息
|
||||
type UserInfoGetBySelfResponse struct {
|
||||
Username string `json:"username"`
|
||||
Avatar string `json:"avatar"` // 可访问 URL(COS key 已转为 CDN 签名地址)
|
||||
Status int8 `json:"status"` // 状态 1=正常 2=临时禁用 3=永久禁用 4=已注销
|
||||
UserUUID string `json:"user_uuid"`
|
||||
}
|
||||
|
||||
// UserProfileUpdateBySelfRequest 修改用户资料
|
||||
type UserProfileUpdateBySelfRequest struct {
|
||||
Username string `json:"username" binding:"max=32"` // 昵称
|
||||
Avatar string `json:"avatar" binding:"max=500"` // 头像地址
|
||||
}
|
||||
|
||||
// UserProfileUpdateBySelfResponse 修改用户资料响应
|
||||
type UserProfileUpdateBySelfResponse struct{}
|
||||
|
||||
// UserAvatarUploadRequest 上传头像(multipart,字段名 file)
|
||||
type UserAvatarUploadRequest struct{}
|
||||
|
||||
// UserAvatarUploadResponse 上传头像响应
|
||||
type UserAvatarUploadResponse struct {
|
||||
Avatar string `json:"avatar"` // 头像可访问 URL
|
||||
}
|
||||
|
||||
// UserRefreshTokenRequest 刷新用户token请求。
|
||||
// 纯 JWT 无状态方案:只校验 refresh token 本身,不校验/吊销服务端会话。
|
||||
type UserRefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// UserRefreshTokenResponse 刷新用户token响应
|
||||
type UserRefreshTokenResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
ExpireTime int64 `json:"expire_time"` // access_token 过期时间(绝对 Unix 秒时间戳)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// CORS 跨域中间件。
|
||||
// 允许来源通过 Server.Domain 配置(如 test.com),自动放行其 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,77 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"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()
|
||||
}
|
||||
|
||||
// maxUserAgentLen user_agent 日志字段最大长度:超出截断,避免异常长 UA 撑大日志体积。
|
||||
const maxUserAgentLen = 256
|
||||
|
||||
// truncateUserAgent 截断超长 User-Agent(仅用于日志展示)。
|
||||
func truncateUserAgent(ua string) string {
|
||||
if len(ua) <= maxUserAgentLen {
|
||||
return ua
|
||||
}
|
||||
return ua[:maxUserAgentLen] + "...(truncated)"
|
||||
}
|
||||
|
||||
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()
|
||||
|
||||
// 请求结束后按状态码分级打日志:5xx=Error、4xx(除 401)=Warn、其余=Info。
|
||||
ctx := c.Request.Context()
|
||||
status := c.Writer.Status()
|
||||
attrs := []any{
|
||||
"request_id", GetRequestID(ctx),
|
||||
"method", c.Request.Method,
|
||||
"path", path,
|
||||
"query", query,
|
||||
"status", status,
|
||||
"ip", c.ClientIP(),
|
||||
"latency", time.Since(start).String(),
|
||||
"user_agent", truncateUserAgent(c.Request.UserAgent()),
|
||||
}
|
||||
switch {
|
||||
case status >= http.StatusInternalServerError:
|
||||
slog.ErrorContext(ctx, "GIN_REQUEST", attrs...)
|
||||
case status >= http.StatusBadRequest && status != http.StatusUnauthorized:
|
||||
slog.WarnContext(ctx, "GIN_REQUEST", attrs...)
|
||||
default:
|
||||
slog.InfoContext(ctx, "GIN_REQUEST", attrs...)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type ctxKey string
|
||||
|
||||
const (
|
||||
userIDKey ctxKey = "userID"
|
||||
)
|
||||
|
||||
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 == "" {
|
||||
slog.WarnContext(c.Request.Context(), "jwt auth: authorization header missing",
|
||||
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenMissing)
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || parts[0] != "Bearer" {
|
||||
slog.WarnContext(c.Request.Context(), "jwt auth: invalid authorization format",
|
||||
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP())
|
||||
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):
|
||||
slog.InfoContext(c.Request.Context(), "jwt auth: token expired", "path", c.Request.URL.Path)
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenTimeout)
|
||||
case errors.Is(err, lzcwyjwt.ErrTokenWrongType):
|
||||
slog.WarnContext(c.Request.Context(), "jwt auth: token type mismatch", "path", c.Request.URL.Path)
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails("token type mismatch"))
|
||||
default:
|
||||
slog.WarnContext(c.Request.Context(), "jwt auth: parse token failed",
|
||||
"path", c.Request.URL.Path, "ip", c.ClientIP(), "error", err)
|
||||
response.ToErrorResponse(errcode.UnauthorizedTokenError.WithDetails(err.Error()))
|
||||
}
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 4. 把 userID注入 context
|
||||
ctx := context.WithValue(c.Request.Context(), userIDKey, claims.UserID)
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从 context 取登录用户 ID(在 handler 里用)
|
||||
func GetUserID(ctx context.Context) (int64, bool) {
|
||||
uid, ok := ctx.Value(userIDKey).(int64)
|
||||
return uid, ok
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Recovery 替代 gin.Recovery():panic 恢复语义保持一致(客户端断开类 panic 仅记录并中止,
|
||||
// 其余 panic 返回 500 Internal Server Error),但 panic 与堆栈统一走 slog 结构化输出,
|
||||
// 与全站 JSON 日志(含 request_id)对齐,避免默认实现写 stderr 非结构化日志。
|
||||
func Recovery() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
ctx := c.Request.Context()
|
||||
if isBrokenPipe(r) {
|
||||
// 连接已断开,无法再写响应:仅记录并中止。
|
||||
slog.ErrorContext(ctx, "GIN_PANIC_BROKEN_PIPE",
|
||||
"method", c.Request.Method, "path", c.Request.URL.Path, "recover", fmt.Sprint(r))
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
slog.ErrorContext(ctx, "GIN_PANIC",
|
||||
"method", c.Request.Method, "path", c.Request.URL.Path, "ip", c.ClientIP(),
|
||||
"recover", fmt.Sprint(r), "stack", string(debug.Stack()))
|
||||
c.AbortWithStatus(http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// isBrokenPipe 判定 panic 是否源于客户端断开(broken pipe / connection reset),与 gin.Recovery 判定一致。
|
||||
func isBrokenPipe(r any) bool {
|
||||
ne, ok := r.(*net.OpError)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
var se *os.SyscallError
|
||||
if !errors.As(ne, &se) {
|
||||
return false
|
||||
}
|
||||
seStr := strings.ToLower(se.Error())
|
||||
return strings.Contains(seStr, "broken pipe") || strings.Contains(seStr, "connection reset by peer")
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
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,63 @@
|
||||
package mysqldao
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserDao struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserDao() *UserDao {
|
||||
return &UserDao{db: global.MysqlEngine}
|
||||
}
|
||||
|
||||
func (d *UserDao) UserCreate(ctx context.Context, user *mysqlmodel.User) error {
|
||||
if err := d.db.WithContext(ctx).Create(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *UserDao) UserUpdateByID(ctx context.Context, user *mysqlmodel.User) error {
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("id = ?", user.ID).Updates(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *UserDao) UserUpdateByUUID(ctx context.Context, user *mysqlmodel.User) error {
|
||||
if err := d.db.WithContext(ctx).Model(&mysqlmodel.User{}).Where("user_uuid = ?", user.UserUUID).Updates(user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *UserDao) UserGetByID(ctx context.Context, id int64) (*mysqlmodel.User, error) {
|
||||
var user mysqlmodel.User
|
||||
if err := d.db.WithContext(ctx).Where("id = ?", id).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (d *UserDao) UserGetByWechatOpenID(ctx context.Context, openID string) (*mysqlmodel.User, error) {
|
||||
var user mysqlmodel.User
|
||||
if err := d.db.WithContext(ctx).Where("wechat_open_id = ?", openID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (d *UserDao) UserGetByUUID(ctx context.Context, userUUID string) (*mysqlmodel.User, error) {
|
||||
var user mysqlmodel.User
|
||||
if err := d.db.WithContext(ctx).Where("user_uuid = ?", userUUID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package mysqlmodel
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package mysqlmodel
|
||||
|
||||
// 用户状态:1=正常 2=临时禁用 3=永久禁用 4=已注销。
|
||||
const (
|
||||
UserStatusNormal int8 = 1
|
||||
UserStatusTempBanned int8 = 2
|
||||
UserStatusBanned int8 = 3
|
||||
UserStatusCancelled int8 = 4
|
||||
)
|
||||
|
||||
type User struct {
|
||||
Model
|
||||
UserUUID string `gorm:"column:user_uuid;type:varchar(32);not null;uniqueIndex:idx_user_uuid"`
|
||||
WechatOpenID string `gorm:"column:wechat_open_id;type:varchar(32);not null;uniqueIndex:idx_wechat_open_id"`
|
||||
Username string `gorm:"column:username;type:varchar(64);not null"`
|
||||
Avatar string `gorm:"column:avatar;type:varchar(255);not null"`
|
||||
Status int8 `gorm:"column:status;type:tinyint(1);not null"`
|
||||
}
|
||||
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package v1 提供 /api/v1 版本的 HTTP handler,负责请求绑定、鉴权透传与统一响应,
|
||||
// 业务逻辑与归属校验均在 internal/service 层完成。
|
||||
package v1
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/service"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/app"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-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()}
|
||||
}
|
||||
|
||||
// WxLogin 微信登录(公开)。
|
||||
func (h *UserHandler) WxLogin(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
var request dto.UserWxLoginRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.WxLogin(c.Request.Context(), &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// RefreshToken 刷新访问令牌(公开)。
|
||||
func (h *UserHandler) RefreshToken(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
var request dto.UserRefreshTokenRequest
|
||||
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)
|
||||
}
|
||||
|
||||
// InfoGetBySelf 获取当前用户信息。
|
||||
func (h *UserHandler) InfoGetBySelf(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
out, e := h.svc.InfoGetBySelf(c.Request.Context(), userID)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// ProfileUpdate 修改当前用户资料。
|
||||
func (h *UserHandler) ProfileUpdate(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
var request dto.UserProfileUpdateBySelfRequest
|
||||
if err := c.ShouldBindJSON(&request); err != nil {
|
||||
response.ToErrorResponse(errcode.InvalidParams.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.ProfileUpdate(c.Request.Context(), userID, &request)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
|
||||
// AvatarUpload 上传用户头像(multipart,字段名 file)。
|
||||
func (h *UserHandler) AvatarUpload(c *gin.Context) {
|
||||
response := app.NewResponse(c)
|
||||
userID, ok := middleware.GetUserID(c.Request.Context())
|
||||
if !ok {
|
||||
response.ToErrorResponse(errcode.Unauthorized)
|
||||
return
|
||||
}
|
||||
// 解析 multipart 前限制请求体大小(MaxSizeMB + 1MB 的 multipart 边界开销),
|
||||
// 防止超大 body 在 FormFile 解析时耗尽内存/磁盘。
|
||||
maxBytes := int64(global.UploadSetting.MaxSizeMB)*1024*1024 + 1024*1024
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
|
||||
fileHeader, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
response.ToErrorResponse(errcode.UploadFileEmpty.WithDetails(err.Error()))
|
||||
return
|
||||
}
|
||||
out, e := h.svc.AvatarUpload(c.Request.Context(), userID, fileHeader)
|
||||
if e != nil {
|
||||
response.ToErrorResponse(e)
|
||||
return
|
||||
}
|
||||
response.ToResponse(out)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package routers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/middleware"
|
||||
v1 "git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers/api/v1"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// NewRouter 构建 gin 引擎:注册中间件链与 /api/v1 路由。
|
||||
func NewRouter() *gin.Engine {
|
||||
r := gin.New()
|
||||
|
||||
// 中间件链:Recovery -> RequestID -> Slog -> CORS
|
||||
r.Use(
|
||||
middleware.Recovery(),
|
||||
middleware.RequestID(),
|
||||
middleware.GinSlogMiddleware(),
|
||||
middleware.CORS(),
|
||||
)
|
||||
|
||||
// 健康检查
|
||||
r.GET("/ping", func(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
||||
})
|
||||
|
||||
// handler 实例
|
||||
userHandler := v1.NewUserHandler()
|
||||
|
||||
apiV1 := r.Group("/api/v1")
|
||||
|
||||
// ===== 公开路由(无需登录) =====
|
||||
{
|
||||
apiV1.POST("/user/wxlogin", userHandler.WxLogin)
|
||||
apiV1.POST("/user/refresh-token", userHandler.RefreshToken)
|
||||
}
|
||||
|
||||
// ===== 鉴权路由(需登录) =====
|
||||
auth := apiV1.Group("")
|
||||
auth.Use(middleware.JWTAuth())
|
||||
{
|
||||
auth.GET("/user/info", userHandler.InfoGetBySelf)
|
||||
auth.PUT("/user/profile", userHandler.ProfileUpdate)
|
||||
auth.POST("/user/avatar/upload", userHandler.AvatarUpload)
|
||||
}
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/dto"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqldao"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/errcode"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/lzcwyjwt"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/upload"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/wechat"
|
||||
"github.com/go-sql-driver/mysql"
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 图片魔数校验需要读取的头部字节数(jpeg/png/gif/webp 最长 12 字节)。
|
||||
const imageMagicHeaderLen = 12
|
||||
|
||||
// UserService 用户业务服务。
|
||||
type UserService struct {
|
||||
userDao *mysqldao.UserDao
|
||||
}
|
||||
|
||||
// NewUserService 创建用户服务。
|
||||
func NewUserService() *UserService {
|
||||
return &UserService{
|
||||
userDao: mysqldao.NewUserDao(),
|
||||
}
|
||||
}
|
||||
|
||||
// WxLogin 微信小程序登录:code2session 换取 openid,首次登录自动注册。
|
||||
func (s *UserService) WxLogin(ctx context.Context, request *dto.UserWxLoginRequest) (*dto.UserWxLoginResponse, *errcode.Error) {
|
||||
sess, err := wechat.Code2Session(ctx, request.Code)
|
||||
if err != nil {
|
||||
var codeErr *wechat.Code2SessionError
|
||||
if errors.As(err, &codeErr) && codeErr.IsInvalidCode() {
|
||||
slog.WarnContext(ctx, "wx login: invalid code", "error", err)
|
||||
return nil, errcode.UserWxCodeInvalid
|
||||
}
|
||||
slog.ErrorContext(ctx, "wx login: code2session failed", "error", err)
|
||||
return nil, errcode.UserWxLoginFailed
|
||||
}
|
||||
|
||||
user, err := s.userDao.UserGetByWechatOpenID(ctx, sess.OpenID)
|
||||
if err != nil {
|
||||
if !isNotFound(err) {
|
||||
slog.ErrorContext(ctx, "wx login: query user failed", "error", err)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
// 首次登录:注册新用户。
|
||||
user = &mysqlmodel.User{
|
||||
UserUUID: genUserUUID(),
|
||||
WechatOpenID: sess.OpenID,
|
||||
Username: "微信用户",
|
||||
Status: mysqlmodel.UserStatusNormal,
|
||||
}
|
||||
if cerr := s.userDao.UserCreate(ctx, user); cerr != nil {
|
||||
if isDuplicateKey(cerr) {
|
||||
// 并发注册冲突:重查已存在的用户继续登录。
|
||||
slog.InfoContext(ctx, "wx login: concurrent register conflict, reload user")
|
||||
user, err = s.userDao.UserGetByWechatOpenID(ctx, sess.OpenID)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "wx login: reload user after register conflict failed", "error", err)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
} else {
|
||||
slog.ErrorContext(ctx, "wx login: create user failed", "error", cerr)
|
||||
return nil, errcode.UserCreateFailed
|
||||
}
|
||||
} else {
|
||||
slog.InfoContext(ctx, "wx login: new user registered", "user_id", user.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// 非正常状态账号(临时/永久禁用、已注销)禁止登录。
|
||||
if user.Status != mysqlmodel.UserStatusNormal {
|
||||
slog.WarnContext(ctx, "wx login: user disabled", "user_id", user.ID, "status", user.Status)
|
||||
return nil, errcode.UserDisabled
|
||||
}
|
||||
|
||||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
|
||||
if terr != nil {
|
||||
slog.ErrorContext(ctx, "wx login: generate tokens failed", "user_id", user.ID, "error", terr)
|
||||
return nil, errcode.UnauthorizedTokenGenerate
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "user login", "user_id", user.ID)
|
||||
|
||||
return &dto.UserWxLoginResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||
UserInfo: buildUserInfoResponse(user),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RefreshToken 使用 refresh token 刷新访问令牌。
|
||||
// 纯 JWT 无状态方案:不吊销旧令牌,仅校验令牌本身与用户当前状态后重新签发。
|
||||
func (s *UserService) RefreshToken(ctx context.Context, request *dto.UserRefreshTokenRequest) (*dto.UserRefreshTokenResponse, *errcode.Error) {
|
||||
claims, err := lzcwyjwt.ParseRefreshToken(request.RefreshToken)
|
||||
if err != nil {
|
||||
if errors.Is(err, lzcwyjwt.ErrTokenExpired) {
|
||||
slog.InfoContext(ctx, "refresh token: token expired")
|
||||
return nil, errcode.UnauthorizedTokenTimeout
|
||||
}
|
||||
slog.WarnContext(ctx, "refresh token: parse token failed", "error", err)
|
||||
return nil, errcode.UnauthorizedTokenError
|
||||
}
|
||||
|
||||
// 校验用户仍存在且状态正常,避免已注销/封禁账号凭旧 refresh token 续期。
|
||||
user, uerr := s.userDao.UserGetByID(ctx, claims.UserID)
|
||||
if uerr != nil {
|
||||
if isNotFound(uerr) {
|
||||
slog.WarnContext(ctx, "refresh token: user not found", "user_id", claims.UserID)
|
||||
return nil, errcode.UserNotFound
|
||||
}
|
||||
slog.ErrorContext(ctx, "refresh token: query user failed", "user_id", claims.UserID, "error", uerr)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
if user.Status != mysqlmodel.UserStatusNormal {
|
||||
slog.WarnContext(ctx, "refresh token: user disabled", "user_id", user.ID, "status", user.Status)
|
||||
return nil, errcode.UserDisabled
|
||||
}
|
||||
|
||||
accessToken, refreshToken, terr := lzcwyjwt.GenerateTokens(user.ID)
|
||||
if terr != nil {
|
||||
slog.ErrorContext(ctx, "refresh token: generate tokens failed", "user_id", user.ID, "error", terr)
|
||||
return nil, errcode.UnauthorizedTokenGenerate
|
||||
}
|
||||
|
||||
slog.DebugContext(ctx, "refresh token: tokens rotated", "user_id", user.ID)
|
||||
|
||||
return &dto.UserRefreshTokenResponse{
|
||||
AccessToken: accessToken,
|
||||
RefreshToken: refreshToken,
|
||||
ExpireTime: nowUnix() + int64(global.JWTSetting.AccessTokenExpire),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// InfoGetBySelf 获取当前用户信息。
|
||||
func (s *UserService) InfoGetBySelf(ctx context.Context, userID int64) (*dto.UserInfoGetBySelfResponse, *errcode.Error) {
|
||||
user, err := s.userDao.UserGetByID(ctx, userID)
|
||||
if err != nil {
|
||||
if isNotFound(err) {
|
||||
slog.WarnContext(ctx, "get user info: user not found", "user_id", userID)
|
||||
return nil, errcode.UserNotFound
|
||||
}
|
||||
slog.ErrorContext(ctx, "get user info: query user failed", "user_id", userID, "error", err)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
info := buildUserInfoResponse(user)
|
||||
return &info, nil
|
||||
}
|
||||
|
||||
// ProfileUpdate 修改当前用户资料,仅更新提交的非空字段。
|
||||
func (s *UserService) ProfileUpdate(ctx context.Context, userID int64, request *dto.UserProfileUpdateBySelfRequest) (*dto.UserProfileUpdateBySelfResponse, *errcode.Error) {
|
||||
// 字段校验:昵称长度、头像长度(binding 已限制字节长度,此处按字符数二次校验)。
|
||||
if u := strings.TrimSpace(request.Username); u != "" {
|
||||
if len([]rune(u)) > 32 {
|
||||
return nil, errcode.InvalidParams.WithDetails("昵称长度不能超过 32 个字符")
|
||||
}
|
||||
request.Username = u
|
||||
}
|
||||
if request.Avatar != "" && len([]rune(request.Avatar)) > 500 {
|
||||
return nil, errcode.InvalidParams.WithDetails("头像地址过长")
|
||||
}
|
||||
|
||||
// GORM Updates(struct) 忽略零值字段,仅非空字段参与更新。
|
||||
updates := &mysqlmodel.User{Model: mysqlmodel.Model{ID: userID}}
|
||||
changed := false
|
||||
if request.Username != "" {
|
||||
updates.Username = request.Username
|
||||
changed = true
|
||||
}
|
||||
if request.Avatar != "" {
|
||||
updates.Avatar = request.Avatar
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return &dto.UserProfileUpdateBySelfResponse{}, nil
|
||||
}
|
||||
|
||||
if err := s.userDao.UserUpdateByID(ctx, updates); err != nil {
|
||||
slog.ErrorContext(ctx, "profile update: update user failed", "user_id", userID, "error", err)
|
||||
return nil, errcode.UserUpdateFailed
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "profile update success", "user_id", userID)
|
||||
|
||||
return &dto.UserProfileUpdateBySelfResponse{}, nil
|
||||
}
|
||||
|
||||
// AvatarUpload 上传用户头像:校验图片 → 落临时目录 → 上传 COS → 更新 users.avatar(存 COS key),
|
||||
// 返回可直接访问的头像 URL。
|
||||
func (s *UserService) AvatarUpload(ctx context.Context, userID int64, fileHeader *multipart.FileHeader) (*dto.UserAvatarUploadResponse, *errcode.Error) {
|
||||
if fileHeader == nil || fileHeader.Size == 0 {
|
||||
slog.DebugContext(ctx, "avatar upload: empty file", "user_id", userID)
|
||||
return nil, errcode.UploadFileEmpty
|
||||
}
|
||||
|
||||
// 1. 扩展名与大小校验。
|
||||
ext := upload.FileGetExt(fileHeader.Filename)
|
||||
if !upload.FileCheckAllowExt(ext, global.UploadSetting.AllowedExtensions) {
|
||||
slog.DebugContext(ctx, "avatar upload: extension not allowed", "user_id", userID, "ext", ext)
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
if !upload.FileCheckMaxSize(fileHeader.Size, int64(global.UploadSetting.MaxSizeMB)) {
|
||||
slog.DebugContext(ctx, "avatar upload: file too large", "user_id", userID, "size", fileHeader.Size)
|
||||
return nil, errcode.UploadFileTooLarge
|
||||
}
|
||||
|
||||
// 2. 只读文件头做真实图片校验(防伪造扩展名上传脚本等),避免整图读入内存。
|
||||
header, herr := readFileHeader(fileHeader, imageMagicHeaderLen)
|
||||
if herr != nil {
|
||||
slog.ErrorContext(ctx, "avatar upload: read file header failed", "user_id", userID, "error", herr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
if !upload.FileCheckImageMagic(header) {
|
||||
slog.WarnContext(ctx, "avatar upload: magic bytes check failed", "user_id", userID, "size", fileHeader.Size)
|
||||
return nil, errcode.UploadFileExtNotAllow
|
||||
}
|
||||
|
||||
// 3. 取用户 UUID,构造 COS key:{userUUID}/{userID}/avatar/{随机名+扩展名}。
|
||||
user, uerr := s.userDao.UserGetByID(ctx, userID)
|
||||
if uerr != nil {
|
||||
if isNotFound(uerr) {
|
||||
slog.WarnContext(ctx, "avatar upload: user not found", "user_id", userID)
|
||||
return nil, errcode.UserNotFound
|
||||
}
|
||||
slog.ErrorContext(ctx, "avatar upload: query user failed", "user_id", userID, "error", uerr)
|
||||
return nil, errcode.DatabaseError
|
||||
}
|
||||
|
||||
fileName, nerr := upload.FileGenName(fileHeader.Filename)
|
||||
if nerr != nil {
|
||||
slog.ErrorContext(ctx, "avatar upload: generate file name failed", "user_id", userID, "error", nerr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
|
||||
// 4. 落临时文件(流式复制),无论成败都必须清理:
|
||||
// 上传接口无频率限制,临时文件残留会让单个认证用户循环上传写满磁盘。
|
||||
tmpPath, serr := upload.FileSaveTmp(ctx, fileHeader, fileName, userID)
|
||||
if serr != nil {
|
||||
slog.ErrorContext(ctx, "avatar upload: save temp file failed", "user_id", userID, "error", serr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
defer func() {
|
||||
if rerr := os.Remove(tmpPath); rerr != nil && !os.IsNotExist(rerr) {
|
||||
slog.WarnContext(ctx, "avatar upload: remove temp file failed",
|
||||
"user_id", userID, "tmp_path", tmpPath, "error", rerr)
|
||||
}
|
||||
}()
|
||||
|
||||
cosKey := fmt.Sprintf("%s/%d/avatar/%s", user.UserUUID, userID, fileName)
|
||||
if cerr := upload.FileUploadCos(ctx, cosKey, tmpPath); cerr != nil {
|
||||
slog.ErrorContext(ctx, "avatar upload: upload to cos failed", "user_id", userID, "cos_key", cosKey, "error", cerr)
|
||||
return nil, errcode.UploadFileSaveFailed
|
||||
}
|
||||
|
||||
// 5. avatar 存 COS key,读取时统一转可访问 URL。
|
||||
if uerr := s.userDao.UserUpdateByID(ctx, &mysqlmodel.User{Model: mysqlmodel.Model{ID: userID}, Avatar: cosKey}); uerr != nil {
|
||||
slog.ErrorContext(ctx, "avatar upload: update user avatar failed", "user_id", userID, "error", uerr)
|
||||
return nil, errcode.UserUpdateFailed
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "avatar upload success", "user_id", userID, "cos_key", cosKey)
|
||||
|
||||
return &dto.UserAvatarUploadResponse{Avatar: upload.GetCosUrl(cosKey)}, nil
|
||||
}
|
||||
|
||||
// buildUserInfoResponse 将用户模型映射为用户信息响应,头像统一转为可访问 URL。
|
||||
func buildUserInfoResponse(user *mysqlmodel.User) dto.UserInfoGetBySelfResponse {
|
||||
return dto.UserInfoGetBySelfResponse{
|
||||
Username: user.Username,
|
||||
Avatar: upload.AvatarURL(user.Avatar),
|
||||
Status: user.Status,
|
||||
UserUUID: user.UserUUID,
|
||||
}
|
||||
}
|
||||
|
||||
// readFileHeader 同步读取上传文件头部最多 n 字节(请求结束 multipart 临时文件即被清理,
|
||||
// 需在本函数内完成读取)。
|
||||
func readFileHeader(fileHeader *multipart.FileHeader, n int) ([]byte, error) {
|
||||
src, err := fileHeader.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
buf := make([]byte, n)
|
||||
read, err := io.ReadFull(src, buf)
|
||||
if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) {
|
||||
return nil, err
|
||||
}
|
||||
return buf[:read], nil
|
||||
}
|
||||
|
||||
// isNotFound 判断 gorm 是否为「记录不存在」。
|
||||
func isNotFound(err error) bool {
|
||||
return errors.Is(err, gorm.ErrRecordNotFound)
|
||||
}
|
||||
|
||||
// isDuplicateKey 判断是否为 MySQL 唯一键冲突(Error 1062),用于并发注册回退重查。
|
||||
func isDuplicateKey(err error) bool {
|
||||
var mysqlErr *mysql.MySQLError
|
||||
return errors.As(err, &mysqlErr) && mysqlErr.Number == 1062
|
||||
}
|
||||
|
||||
// nowUnix 当前秒级时间戳。
|
||||
func nowUnix() int64 {
|
||||
return time.Now().Unix()
|
||||
}
|
||||
|
||||
// genUserUUID 生成 32 位十六进制用户标识(去连字符 UUID),与 users.user_uuid varchar(32) 对齐。
|
||||
func genUserUUID() string {
|
||||
return strings.ReplaceAll(uuid.NewString(), "-", "")
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/mysqlmodel"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/internal/routers"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/cos"
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/pkg/setting"
|
||||
"github.com/gin-gonic/gin"
|
||||
"gopkg.in/natefinch/lumberjack.v2"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if err := setupSetting(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := setupSlogLogger(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
if err := setupMysqlEngine(); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
setupCosClient()
|
||||
}
|
||||
|
||||
func main() {
|
||||
gin.SetMode(global.ServerSetting.RunMode)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// 优雅关闭:等待在途请求处理完成,超时 5 秒强制退出。
|
||||
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)
|
||||
}
|
||||
|
||||
slog.Info("server exited")
|
||||
}
|
||||
|
||||
// setupSetting 加载 configs/config.yaml 各配置段到 global,并完成必填校验与默认值兜底。
|
||||
//
|
||||
// 注意:Redis 段只读取不建连接(本轮不接入缓存),保留读取是为了配置结构完整、
|
||||
// 后续启用缓存时无需改动此处顺序。
|
||||
func setupSetting() error {
|
||||
s, err := setting.NewSetting()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.ReadSection("Server", &global.ServerSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("Log", &global.LogSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("Mysql", &global.MysqlSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("Redis", &global.RedisSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("JWT", &global.JWTSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("Upload", &global.UploadSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("Cos", &global.CosSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := s.ReadSection("WechatMini", &global.WechatMiniSetting); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// ===== 必填校验:缺失即启动失败,避免运行期才发现配置错误 =====
|
||||
if global.ServerSetting == nil {
|
||||
return errors.New("server config section is missing")
|
||||
}
|
||||
if global.LogSetting == nil {
|
||||
return errors.New("log config section is missing")
|
||||
}
|
||||
if global.MysqlSetting == nil || global.MysqlSetting.DBName == "" {
|
||||
return errors.New("mysql config section is missing or db name is empty")
|
||||
}
|
||||
if global.JWTSetting == nil || global.JWTSetting.SecretKey == "" {
|
||||
return errors.New("jwt secret key must not be empty")
|
||||
}
|
||||
if global.JWTSetting.AccessTokenExpire <= 0 || global.JWTSetting.RefreshTokenExpire <= 0 {
|
||||
return errors.New("jwt token expire must be positive")
|
||||
}
|
||||
|
||||
// ===== 默认值兜底:可选段缺失时给安全默认,避免下游 nil 解引用 =====
|
||||
if global.UploadSetting == nil {
|
||||
global.UploadSetting = &setting.UploadSettingS{}
|
||||
}
|
||||
if global.UploadSetting.MaxSizeMB <= 0 {
|
||||
global.UploadSetting.MaxSizeMB = 20
|
||||
}
|
||||
if global.CosSetting == nil {
|
||||
global.CosSetting = &setting.CosSettingS{}
|
||||
}
|
||||
if global.WechatMiniSetting == nil {
|
||||
global.WechatMiniSetting = &setting.WechatMiniSettingS{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupSlogLogger 初始化全局日志:lumberjack 按大小/天数轮转写文件,同时输出到 stdout,
|
||||
// 采用 JSON 结构化格式并按配置级别过滤。
|
||||
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,
|
||||
LocalTime: true, // 轮转文件名时间戳使用本地时区(默认 UTC,与业务展示时区不一致)
|
||||
}
|
||||
|
||||
multiWriter := io.MultiWriter(writer, os.Stdout)
|
||||
|
||||
// 级别匹配忽略大小写与首尾空白,避免 "debug"/"Info " 等误配静默回落 Info。
|
||||
level := slog.LevelInfo
|
||||
unknownLevel := ""
|
||||
switch strings.ToLower(strings.TrimSpace(global.LogSetting.LogLevel)) {
|
||||
case "debug":
|
||||
level = slog.LevelDebug
|
||||
case "", "info":
|
||||
level = slog.LevelInfo
|
||||
case "warn", "warning":
|
||||
level = slog.LevelWarn
|
||||
case "error":
|
||||
level = slog.LevelError
|
||||
default:
|
||||
unknownLevel = global.LogSetting.LogLevel
|
||||
}
|
||||
|
||||
slog.SetDefault(slog.New(slog.NewJSONHandler(multiWriter, &slog.HandlerOptions{
|
||||
AddSource: true,
|
||||
Level: level,
|
||||
})))
|
||||
|
||||
if unknownLevel != "" {
|
||||
slog.Warn("unknown log level in config, fallback to info", "configured", unknownLevel)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupMysqlEngine 初始化 MySQL 数据库连接。
|
||||
func setupMysqlEngine() error {
|
||||
var err error
|
||||
global.MysqlEngine, err = mysqlmodel.NewMysqlEngine(global.MysqlSetting)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setupCosClient 初始化腾讯云 COS 客户端(头像上传依赖)。
|
||||
// 初始化失败仅告警,不阻断服务启动(上传时返回明确的业务错误)。
|
||||
func setupCosClient() {
|
||||
client, err := cos.NewCosClient()
|
||||
if err != nil {
|
||||
slog.Warn("init cos client failed, upload features may fail", "error", err)
|
||||
return
|
||||
}
|
||||
global.CosClient = client
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-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,26 @@
|
||||
package cos
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"github.com/tencentyun/cos-go-sdk-v5"
|
||||
)
|
||||
|
||||
func NewCosClient() (*cos.Client, error) {
|
||||
u, err := url.Parse("https://" + global.CosSetting.CosUploadDomain)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse cos upload domain: %w", err)
|
||||
}
|
||||
b := &cos.BaseURL{BucketURL: u}
|
||||
client := cos.NewClient(b, &http.Client{
|
||||
Transport: &cos.AuthorizationTransport{
|
||||
SecretID: global.CosSetting.CosSecretID,
|
||||
SecretKey: global.CosSetting.CosSecretKey,
|
||||
},
|
||||
})
|
||||
|
||||
return client, nil
|
||||
}
|
||||
@@ -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,14 @@
|
||||
package errcode
|
||||
|
||||
// 上传模块错误码段位:30000 - 39999
|
||||
//
|
||||
// 细分:
|
||||
// 30000 - 30999 参数 / 校验
|
||||
// 39000 - 39999 服务端错误
|
||||
|
||||
var (
|
||||
UploadFileEmpty = NewError(30000, "上传文件为空")
|
||||
UploadFileExtNotAllow = NewError(30001, "不支持的文件类型")
|
||||
UploadFileTooLarge = NewError(30002, "文件大小超过限制")
|
||||
UploadFileSaveFailed = NewError(39000, "文件保存失败")
|
||||
)
|
||||
@@ -0,0 +1,28 @@
|
||||
package errcode
|
||||
|
||||
// 用户模块错误码段位:20000 - 29999
|
||||
//
|
||||
// 细分:
|
||||
// 20000 - 20999 参数错误
|
||||
// 21000 - 21999 鉴权 / 登录
|
||||
// 22000 - 22999 资源不存在
|
||||
// 23000 - 23999 冲突 / 重复
|
||||
// 29000 - 29999 服务端错误
|
||||
|
||||
// ===== 鉴权 / 登录(21000 - 21999) =====
|
||||
var (
|
||||
UserDisabled = NewError(21001, "账号已被禁用")
|
||||
UserWxLoginFailed = NewError(21002, "微信登录失败")
|
||||
UserWxCodeInvalid = NewError(21003, "微信 Code 无效")
|
||||
)
|
||||
|
||||
// ===== 资源不存在(22000 - 22999) =====
|
||||
var (
|
||||
UserNotFound = NewError(22000, "用户不存在")
|
||||
)
|
||||
|
||||
// ===== 服务端错误(29000 - 29999) =====
|
||||
var (
|
||||
UserCreateFailed = NewError(29000, "用户创建失败")
|
||||
UserUpdateFailed = NewError(29001, "用户更新失败")
|
||||
)
|
||||
@@ -0,0 +1,137 @@
|
||||
package lzcwyjwt
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
type TokenType string
|
||||
|
||||
const (
|
||||
TokenTypeAccess TokenType = "access"
|
||||
TokenTypeRefresh TokenType = "refresh"
|
||||
)
|
||||
|
||||
type Claims struct {
|
||||
UserID int64 `json:"user_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(userID int64) (accessToken, refreshToken string, err error) {
|
||||
now := time.Now()
|
||||
accessClaims := Claims{
|
||||
UserID: userID,
|
||||
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{
|
||||
UserID: userID,
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 校验 token 类型(防止 refresh token 被当 access token 用)
|
||||
if claims.TokenType != TokenTypeAccess {
|
||||
return nil, ErrTokenWrongType
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
}
|
||||
|
||||
func ParseRefreshToken(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
|
||||
}
|
||||
|
||||
// 校验 token 类型(防止 access token 被当 refresh token 用)
|
||||
if claims.TokenType != TokenTypeRefresh {
|
||||
return nil, ErrTokenWrongType
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package setting
|
||||
|
||||
// ServerSettingS 服务配置。时间字段单位为秒(int),避免 time.Duration 对整数配置按纳秒解析的歧义。
|
||||
type ServerSettingS struct {
|
||||
Domain string // 服务域名(用于 CORS 允许来源,如 test.com)
|
||||
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 JWTSettingS struct {
|
||||
SecretKey string
|
||||
Issuer string
|
||||
AccessTokenExpire int // 秒
|
||||
RefreshTokenExpire int // 秒
|
||||
}
|
||||
|
||||
type UploadSettingS struct {
|
||||
TmpSavePath string
|
||||
MaxSizeMB int
|
||||
AllowedExtensions []string
|
||||
}
|
||||
|
||||
type CosSettingS struct {
|
||||
CosSecretID string
|
||||
CosSecretKey string
|
||||
CosUploadDomain string
|
||||
CosDownloadDomain string
|
||||
CdnDownloadSecretKey string
|
||||
}
|
||||
|
||||
// WechatMiniSettingS 微信小程序配置(用于 code2session 登录)。
|
||||
type WechatMiniSettingS struct {
|
||||
AppID string
|
||||
AppSecret string
|
||||
}
|
||||
|
||||
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,22 @@
|
||||
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,186 @@
|
||||
// Package upload 文件上传、校验、落盘、公开 token 生成的工具函数。
|
||||
package upload
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
)
|
||||
|
||||
func FileGetExt(name string) string {
|
||||
return strings.ToLower(path.Ext(name))
|
||||
}
|
||||
|
||||
// 校验扩展名是否在白名单内。
|
||||
func FileCheckAllowExt(ext string, allowed []string) bool {
|
||||
ext = strings.TrimPrefix(strings.ToLower(ext), ".")
|
||||
for _, e := range allowed {
|
||||
if strings.ToLower(e) == ext {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// 校验文件大小。maxMB <= 0 表示不限制。
|
||||
func FileCheckMaxSize(size, maxMB int64) bool {
|
||||
if maxMB <= 0 {
|
||||
return true
|
||||
}
|
||||
return size <= maxMB*1024*1024
|
||||
}
|
||||
|
||||
// FileCheckImageMagic 校验文件头 magic bytes,判断内容是否为真实图片(jpeg/png/gif/webp)。
|
||||
// 用于防止伪造扩展名上传非图片文件(如脚本、可执行文件、HTML)。
|
||||
func FileCheckImageMagic(data []byte) bool {
|
||||
if len(data) < 4 {
|
||||
return false
|
||||
}
|
||||
|
||||
// JPEG: FF D8 FF
|
||||
if data[0] == 0xFF && data[1] == 0xD8 && data[2] == 0xFF {
|
||||
return true
|
||||
}
|
||||
|
||||
// PNG: 89 50 4E 47 0D 0A 1A 0A
|
||||
if len(data) >= 8 &&
|
||||
data[0] == 0x89 && data[1] == 0x50 && data[2] == 0x4E && data[3] == 0x47 &&
|
||||
data[4] == 0x0D && data[5] == 0x0A && data[6] == 0x1A && data[7] == 0x0A {
|
||||
return true
|
||||
}
|
||||
|
||||
// GIF: "GIF8"
|
||||
if data[0] == 'G' && data[1] == 'I' && data[2] == 'F' && data[3] == '8' {
|
||||
return true
|
||||
}
|
||||
|
||||
// WebP: "RIFF" .... "WEBP"
|
||||
if len(data) >= 12 &&
|
||||
data[0] == 'R' && data[1] == 'I' && data[2] == 'F' && data[3] == 'F' &&
|
||||
data[8] == 'W' && data[9] == 'E' && data[10] == 'B' && data[11] == 'P' {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GenFileName 生成防冲突随机相对文件名。
|
||||
func FileGenName(originName string) (string, error) {
|
||||
ext := FileGetExt(originName)
|
||||
b := make([]byte, 16)
|
||||
if _, err := rand.Read(b); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b) + ext, nil
|
||||
}
|
||||
|
||||
// 临时保存从前端获取的图片
|
||||
func FileSaveTmp(ctx context.Context, file *multipart.FileHeader, fileName string, userID int64) (string, error) {
|
||||
|
||||
dir := filepath.Join(global.UploadSetting.TmpSavePath, fmt.Sprint(userID))
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
tmpFile := filepath.Join(dir, fileName)
|
||||
|
||||
dst, err := os.Create(tmpFile)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer dst.Close()
|
||||
|
||||
src, err := file.Open()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer src.Close()
|
||||
|
||||
if _, err := io.Copy(dst, src); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return tmpFile, nil
|
||||
|
||||
}
|
||||
|
||||
// FileUploadCos 将本地文件上传至 COS。
|
||||
// key 为对象键(即写入库中的存储值),localPath 为本地文件路径。
|
||||
// key 或 localPath 为空、client 未初始化均返回 error,由调用方映射业务错误码。
|
||||
func FileUploadCos(ctx context.Context, key, localPath string) error {
|
||||
if key == "" || localPath == "" {
|
||||
return fmt.Errorf("upload cos: empty key or local path")
|
||||
}
|
||||
|
||||
// COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
|
||||
if global.CosClient == nil {
|
||||
return fmt.Errorf("cos client not initialized")
|
||||
}
|
||||
|
||||
if _, _, err := global.CosClient.Object.Upload(ctx, key, localPath, nil); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 删除 COS 对象。key 为对象键(即 processed_image 存储值)。
|
||||
// key 为空直接返回 nil;client 未初始化返回 error。
|
||||
// 删除为尽力而为的清理操作,内部使用短超时避免网络异常长时间阻塞调用方。
|
||||
func FileDeleteCos(ctx context.Context, key string) error {
|
||||
if key == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// COS 客户端初始化失败时降级为 nil,此处做保护避免 nil 指针 panic。
|
||||
if global.CosClient == nil {
|
||||
return fmt.Errorf("cos client not initialized")
|
||||
}
|
||||
|
||||
delCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if _, err := global.CosClient.Object.Delete(delCtx, key); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// 获取cos访问url
|
||||
func GetCosUrl(fileName string) string {
|
||||
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,
|
||||
)
|
||||
}
|
||||
|
||||
// AvatarURL 将头像存储值转为可访问 URL:
|
||||
// 历史通过修改资料接口传入的 http(s) URL 直接透传;COS key 统一转为 CDN 签名 URL。
|
||||
func AvatarURL(raw string) string {
|
||||
if raw == "" {
|
||||
return ""
|
||||
}
|
||||
if strings.HasPrefix(raw, "http://") || strings.HasPrefix(raw, "https://") {
|
||||
return raw
|
||||
}
|
||||
return GetCosUrl(raw)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
// Package wechat 封装微信小程序登录与微信支付 V3 的真实对接能力,仅依赖 Go 标准库。
|
||||
//
|
||||
// 安全说明:
|
||||
// - AppSecret / APIv3Key / 商户私钥均来自配置(禁止硬编码)。
|
||||
// - 仅访问微信固定域名(api.weixin.qq.com / api.mch.weixin.qq.com),无 SSRF 风险。
|
||||
package wechat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"git.lzcwy.cn/zhengchiliu/zt-online-game-backend/global"
|
||||
)
|
||||
|
||||
// code2SessionURL 微信小程序登录凭证校验接口。
|
||||
const code2SessionURL = "https://api.weixin.qq.com/sns/jscode2session"
|
||||
|
||||
// code2SessionResp 微信 code2session 响应。
|
||||
type code2SessionResp struct {
|
||||
OpenID string `json:"openid"`
|
||||
SessionKey string `json:"session_key"`
|
||||
UnionID string `json:"unionid"`
|
||||
ErrCode int `json:"errcode"`
|
||||
ErrMsg string `json:"errmsg"`
|
||||
}
|
||||
|
||||
// Code2SessionResult code2session 结果。
|
||||
type Code2SessionResult struct {
|
||||
OpenID string
|
||||
SessionKey string
|
||||
UnionID string
|
||||
}
|
||||
|
||||
// miniHTTPClient 小程序接口 HTTP 客户端(较短超时,微信接口通常很快)。
|
||||
var miniHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
// Code2Session 使用小程序登录 code 换取 openid / session_key。
|
||||
//
|
||||
// 依赖配置 global.WechatMiniSetting.AppID / AppSecret。
|
||||
// 常见错误:40029 code 无效、45011 频率限制、40163 code 已使用。
|
||||
func Code2Session(ctx context.Context, code string) (*Code2SessionResult, error) {
|
||||
if global.WechatMiniSetting == nil || global.WechatMiniSetting.AppID == "" || global.WechatMiniSetting.AppSecret == "" {
|
||||
return nil, fmt.Errorf("wechat mini not configured")
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("appid", global.WechatMiniSetting.AppID)
|
||||
q.Set("secret", global.WechatMiniSetting.AppSecret)
|
||||
q.Set("js_code", code)
|
||||
q.Set("grant_type", "authorization_code")
|
||||
|
||||
reqURL := code2SessionURL + "?" + q.Encode()
|
||||
request, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session build request: %w", err)
|
||||
}
|
||||
|
||||
response, err := miniHTTPClient.Do(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session do request: %w", err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(response.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("code2session read response: %w", err)
|
||||
}
|
||||
if response.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("code2session http status %d: %s", response.StatusCode, string(data))
|
||||
}
|
||||
|
||||
var r code2SessionResp
|
||||
if err := json.Unmarshal(data, &r); err != nil {
|
||||
return nil, fmt.Errorf("code2session decode: %w", err)
|
||||
}
|
||||
if r.ErrCode != 0 {
|
||||
return nil, &Code2SessionError{Code: r.ErrCode, Msg: r.ErrMsg}
|
||||
}
|
||||
if r.OpenID == "" {
|
||||
return nil, fmt.Errorf("code2session empty openid")
|
||||
}
|
||||
|
||||
return &Code2SessionResult{
|
||||
OpenID: r.OpenID,
|
||||
SessionKey: r.SessionKey,
|
||||
UnionID: r.UnionID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Code2SessionError 微信 code2session 业务错误,携带 errcode 便于上层区分 code 无效等场景。
|
||||
type Code2SessionError struct {
|
||||
Code int
|
||||
Msg string
|
||||
}
|
||||
|
||||
func (e *Code2SessionError) Error() string {
|
||||
return fmt.Sprintf("code2session errcode=%d, errmsg=%s", e.Code, e.Msg)
|
||||
}
|
||||
|
||||
// IsInvalidCode 判断是否为「code 无效 / 已使用 / 过期」类错误。
|
||||
func (e *Code2SessionError) IsInvalidCode() bool {
|
||||
switch e.Code {
|
||||
case 40029, // js_code 无效
|
||||
40163, // code been used
|
||||
41008: // missing code
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user