From ff9add8655cd2b8d6f1c6b9582e8ceb34e310b3c Mon Sep 17 00:00:00 2001 From: zhengchiliu <857493511@qq.com> Date: Sun, 27 Sep 2026 15:02:01 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=20HTTP=20=E5=B1=82=EF=BC=9A?= =?UTF-8?q?=E5=AE=9E=E7=8E=B0=E7=94=A8=E6=88=B7=E6=A8=A1=E5=9D=97=E3=80=81?= =?UTF-8?q?=E8=B7=AF=E7=94=B1=E4=B8=8E=E4=B8=AD=E9=97=B4=E4=BB=B6=E6=8E=A5?= =?UTF-8?q?=E7=BA=BF=E3=80=81COS=20=E4=B8=8A=E4=BC=A0=E5=8F=8A=E4=BC=98?= =?UTF-8?q?=E9=9B=85=E5=90=AF=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增用户与上传错误码文件及 Recovery 中间件 - 修复 router.go 导入与中间件链,注册用户路由并补 handler 错误码导入 - 补充用户状态常量与 UserGetByWechatOpenID 查询,修订刷新令牌 dto - 移除 upload 包 OTel 调用,新增 FileUploadCos 上传封装 - 实现 UserService 微信登录/刷新令牌/我的信息/改资料/上传头像 - 裁剪 main.go 配置段并移除 OTel 桥接,补启动与优雅退出 - 新增 configs 示例配置、global 配置结构及 go.mod 依赖 --- .../plans/http-layer-completion_952ece43.md | 151 ++++++++ .gitignore | 1 + configs/config.example.yaml | 70 ++++ global/cos.go | 7 + global/mysql.go | 5 + global/redis.go | 5 + global/setting.go | 14 + go.mod | 66 ++++ go.sum | 177 ++++++++++ internal/dto/user.go | 54 +++ internal/middleware/cors.go | 50 +++ internal/middleware/logger.go | 77 ++++ internal/middleware/lzcwyjwt.go | 75 ++++ internal/middleware/recovery.go | 53 +++ internal/middleware/requestid.go | 38 ++ internal/mysqldao/user.go | 63 ++++ internal/mysqlmodel/model.go | 54 +++ internal/mysqlmodel/user.go | 22 ++ internal/routers/api/v1/user.go | 119 +++++++ internal/routers/router.go | 49 +++ internal/service/user.go | 330 ++++++++++++++++++ main.go | 221 ++++++++++++ pkg/app/response.go | 41 +++ pkg/cos/cosclient.go | 26 ++ pkg/errcode/common_code.go | 43 +++ pkg/errcode/errcode.go | 77 ++++ pkg/errcode/upload_code.go | 14 + pkg/errcode/user_code.go | 28 ++ pkg/lzcwyjwt/lzcwyjwt.go | 137 ++++++++ pkg/setting/section.go | 84 +++++ pkg/setting/setting.go | 22 ++ pkg/upload/upload.go | 186 ++++++++++ pkg/wechat/mini.go | 114 ++++++ 33 files changed, 2473 insertions(+) create mode 100644 .codebuddy/plans/http-layer-completion_952ece43.md create mode 100644 .gitignore create mode 100644 configs/config.example.yaml create mode 100644 global/cos.go create mode 100644 global/mysql.go create mode 100644 global/redis.go create mode 100644 global/setting.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/dto/user.go create mode 100644 internal/middleware/cors.go create mode 100644 internal/middleware/logger.go create mode 100644 internal/middleware/lzcwyjwt.go create mode 100644 internal/middleware/recovery.go create mode 100644 internal/middleware/requestid.go create mode 100644 internal/mysqldao/user.go create mode 100644 internal/mysqlmodel/model.go create mode 100644 internal/mysqlmodel/user.go create mode 100644 internal/routers/api/v1/user.go create mode 100644 internal/routers/router.go create mode 100644 internal/service/user.go create mode 100644 main.go create mode 100644 pkg/app/response.go create mode 100644 pkg/cos/cosclient.go create mode 100644 pkg/errcode/common_code.go create mode 100644 pkg/errcode/errcode.go create mode 100644 pkg/errcode/upload_code.go create mode 100644 pkg/errcode/user_code.go create mode 100644 pkg/lzcwyjwt/lzcwyjwt.go create mode 100644 pkg/setting/section.go create mode 100644 pkg/setting/setting.go create mode 100644 pkg/upload/upload.go create mode 100644 pkg/wechat/mini.go diff --git a/.codebuddy/plans/http-layer-completion_952ece43.md b/.codebuddy/plans/http-layer-completion_952ece43.md new file mode 100644 index 0000000..39ed1c2 --- /dev/null +++ b/.codebuddy/plans/http-layer-completion_952ece43.md @@ -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: 符号引用一致性报告,确保无悬空引用、无未使用导入、编译与静态检查全绿 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f98d7aa --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +configs/config.yaml \ No newline at end of file diff --git a/configs/config.example.yaml b/configs/config.example.yaml new file mode 100644 index 0000000..f03b114 --- /dev/null +++ b/configs/config.example.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) diff --git a/global/cos.go b/global/cos.go new file mode 100644 index 0000000..1293f6a --- /dev/null +++ b/global/cos.go @@ -0,0 +1,7 @@ +package global + +import "github.com/tencentyun/cos-go-sdk-v5" + +var ( + CosClient *cos.Client +) diff --git a/global/mysql.go b/global/mysql.go new file mode 100644 index 0000000..34f5ee4 --- /dev/null +++ b/global/mysql.go @@ -0,0 +1,5 @@ +package global + +import "gorm.io/gorm" + +var MysqlEngine *gorm.DB diff --git a/global/redis.go b/global/redis.go new file mode 100644 index 0000000..b52f530 --- /dev/null +++ b/global/redis.go @@ -0,0 +1,5 @@ +package global + +import "github.com/redis/go-redis/v9" + +var RedisEngine *redis.Client diff --git a/global/setting.go b/global/setting.go new file mode 100644 index 0000000..ece66ec --- /dev/null +++ b/global/setting.go @@ -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 +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..ff585c1 --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6dfe28e --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/dto/user.go b/internal/dto/user.go new file mode 100644 index 0000000..5054330 --- /dev/null +++ b/internal/dto/user.go @@ -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 秒时间戳) +} diff --git a/internal/middleware/cors.go b/internal/middleware/cors.go new file mode 100644 index 0000000..cb826b5 --- /dev/null +++ b/internal/middleware/cors.go @@ -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 +} diff --git a/internal/middleware/logger.go b/internal/middleware/logger.go new file mode 100644 index 0000000..67a07ab --- /dev/null +++ b/internal/middleware/logger.go @@ -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...) + } + } +} diff --git a/internal/middleware/lzcwyjwt.go b/internal/middleware/lzcwyjwt.go new file mode 100644 index 0000000..9e4ce5f --- /dev/null +++ b/internal/middleware/lzcwyjwt.go @@ -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 +} diff --git a/internal/middleware/recovery.go b/internal/middleware/recovery.go new file mode 100644 index 0000000..4e6dbf7 --- /dev/null +++ b/internal/middleware/recovery.go @@ -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") +} diff --git a/internal/middleware/requestid.go b/internal/middleware/requestid.go new file mode 100644 index 0000000..27b6084 --- /dev/null +++ b/internal/middleware/requestid.go @@ -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 +} diff --git a/internal/mysqldao/user.go b/internal/mysqldao/user.go new file mode 100644 index 0000000..7271f13 --- /dev/null +++ b/internal/mysqldao/user.go @@ -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 +} diff --git a/internal/mysqlmodel/model.go b/internal/mysqlmodel/model.go new file mode 100644 index 0000000..2f82490 --- /dev/null +++ b/internal/mysqlmodel/model.go @@ -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 +} diff --git a/internal/mysqlmodel/user.go b/internal/mysqlmodel/user.go new file mode 100644 index 0000000..f17443f --- /dev/null +++ b/internal/mysqlmodel/user.go @@ -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" +} diff --git a/internal/routers/api/v1/user.go b/internal/routers/api/v1/user.go new file mode 100644 index 0000000..19fa70f --- /dev/null +++ b/internal/routers/api/v1/user.go @@ -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) +} diff --git a/internal/routers/router.go b/internal/routers/router.go new file mode 100644 index 0000000..e752082 --- /dev/null +++ b/internal/routers/router.go @@ -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 +} diff --git a/internal/service/user.go b/internal/service/user.go new file mode 100644 index 0000000..2a2f7c7 --- /dev/null +++ b/internal/service/user.go @@ -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(), "-", "") +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..7a62460 --- /dev/null +++ b/main.go @@ -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 +} diff --git a/pkg/app/response.go b/pkg/app/response.go new file mode 100644 index 0000000..abd0c89 --- /dev/null +++ b/pkg/app/response.go @@ -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) +} diff --git a/pkg/cos/cosclient.go b/pkg/cos/cosclient.go new file mode 100644 index 0000000..9685f9b --- /dev/null +++ b/pkg/cos/cosclient.go @@ -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 +} diff --git a/pkg/errcode/common_code.go b/pkg/errcode/common_code.go new file mode 100644 index 0000000..981b178 --- /dev/null +++ b/pkg/errcode/common_code.go @@ -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, "未知错误") +) diff --git a/pkg/errcode/errcode.go b/pkg/errcode/errcode.go new file mode 100644 index 0000000..af8a532 --- /dev/null +++ b/pkg/errcode/errcode.go @@ -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 +} diff --git a/pkg/errcode/upload_code.go b/pkg/errcode/upload_code.go new file mode 100644 index 0000000..e431210 --- /dev/null +++ b/pkg/errcode/upload_code.go @@ -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, "文件保存失败") +) diff --git a/pkg/errcode/user_code.go b/pkg/errcode/user_code.go new file mode 100644 index 0000000..bc011da --- /dev/null +++ b/pkg/errcode/user_code.go @@ -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, "用户更新失败") +) diff --git a/pkg/lzcwyjwt/lzcwyjwt.go b/pkg/lzcwyjwt/lzcwyjwt.go new file mode 100644 index 0000000..8690e9c --- /dev/null +++ b/pkg/lzcwyjwt/lzcwyjwt.go @@ -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 + +} diff --git a/pkg/setting/section.go b/pkg/setting/section.go new file mode 100644 index 0000000..c88c8ae --- /dev/null +++ b/pkg/setting/section.go @@ -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 +} diff --git a/pkg/setting/setting.go b/pkg/setting/setting.go new file mode 100644 index 0000000..25ad722 --- /dev/null +++ b/pkg/setting/setting.go @@ -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 +} diff --git a/pkg/upload/upload.go b/pkg/upload/upload.go new file mode 100644 index 0000000..7d6f6a1 --- /dev/null +++ b/pkg/upload/upload.go @@ -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) +} diff --git a/pkg/wechat/mini.go b/pkg/wechat/mini.go new file mode 100644 index 0000000..d4dd13c --- /dev/null +++ b/pkg/wechat/mini.go @@ -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 +}