From 1eab1b99c100832eba7e9cfa9d0248d671e63a47 Mon Sep 17 00:00:00 2001 From: nepiedg Date: Sat, 4 Apr 2026 02:52:16 +0800 Subject: [PATCH] feat: rename qiniu to oss, add admin upload proxy with thumbnail, add dev-login - Rename all QINIU_* config/code/docs to OSS_* to match actual Alibaba Cloud OSS - Refactor upload module from internal/common/qiniu to internal/common/upload - Add backend proxy upload endpoint (POST /api/admin/marketing/upload) to avoid CORS - Auto-generate compressed thumbnail (800px, JPEG 80%) on admin image upload - Add dev-login endpoint (POST /api/v1/auth/dev-login) for H5 debugging - Add imageutil package for server-side image resizing Made-with: Cursor --- .env.example | 32 +- cmd/api/main.go | 8 +- config/config.go | 30 +- docs/common/README.md | 6 +- docs/common/backend_convention.md | 477 ++++++++++++++++++ docs/common/upload_qiniu.md | 64 +-- docs/marketing/README.md | 2 +- go.mod | 19 +- go.sum | 16 + internal/common/auth/handler/auth_handler.go | 41 ++ internal/common/auth/service/auth_service.go | 45 ++ internal/common/imageutil/resize.go | 72 +++ .../handler/upload_handler.go | 56 +- .../handler/upload_handler_test.go | 44 +- .../service/upload_service.go} | 53 +- .../service/upload_service_test.go} | 24 +- .../marketing/handler/download_handler.go | 200 +++++++- internal/routes/admin_routes.go | 3 +- internal/routes/common_routes.go | 13 +- internal/routes/marketing_routes.go | 3 +- internal/routes/routes.go | 6 +- 21 files changed, 1023 insertions(+), 191 deletions(-) create mode 100644 docs/common/backend_convention.md create mode 100644 internal/common/imageutil/resize.go rename internal/common/{qiniu => upload}/handler/upload_handler.go (66%) rename internal/common/{qiniu => upload}/handler/upload_handler_test.go (62%) rename internal/common/{qiniu/service/qiniu_service.go => upload/service/upload_service.go} (68%) rename internal/common/{qiniu/service/qiniu_service_test.go => upload/service/upload_service_test.go} (82%) diff --git a/.env.example b/.env.example index 3a68571..9de643a 100755 --- a/.env.example +++ b/.env.example @@ -46,25 +46,25 @@ ADMIN_API_TOKEN=replace-with-strong-random-token ADMIN_DEFAULT_USERNAME=admin ADMIN_DEFAULT_PASSWORD=admin123 -# 七牛直传配置(Kodo) -QINIU_ACCESS_KEY=replace-with-access-key -QINIU_SECRET_KEY=replace-with-secret-key -QINIU_BUCKET=replace-with-bucket -# 上传地址:可保持默认(自动调度),也可以配置具体 Region 的 up 域名 -QINIU_UPLOAD_URL=https://upload.qiniup.com -# CDN 域名(可选):用于拼接最终访问地址,例如 https://cdn.example.com -QINIU_CDN_DOMAIN= +# 阿里云 OSS 直传配置 +OSS_ACCESS_KEY=replace-with-access-key +OSS_SECRET_KEY=replace-with-secret-key +OSS_BUCKET=replace-with-bucket +# 上传地址(可选):OSS 时自动根据 endpoint 计算,留空即可 +OSS_UPLOAD_URL= +# CDN 域名:阿里云 OSS endpoint,例如 oss-cn-beijing.aliyuncs.com +OSS_CDN_DOMAIN= # 上传 key 前缀(可选) -QINIU_KEY_PREFIX=uploads/ -# token 有效期(秒) -QINIU_TOKEN_EXPIRE_SECONDS=300 -# 上传回调地址(可选):配置后,七牛上传成功会回调该地址 -# 示例: https://api.example.com/api/v1/common/upload/qiniu/callback -QINIU_CALLBACK_URL= +OSS_KEY_PREFIX=uploads/ +# 凭证有效期(秒) +OSS_TOKEN_EXPIRE_SECONDS=300 +# 上传回调地址(可选):上传成功后回调该地址 +# 示例: https://api.example.com/api/v1/common/upload/oss/callback +OSS_CALLBACK_URL= # 回调内容模板(可选) -QINIU_CALLBACK_BODY=key=$(key)&hash=$(etag)&fsize=$(fsize)&mimeType=$(mimeType) +OSS_CALLBACK_BODY=key=$(key)&hash=$(etag)&fsize=$(fsize)&mimeType=$(mimeType) # 回调内容类型(可选) -QINIU_CALLBACK_BODY_TYPE=application/x-www-form-urlencoded +OSS_CALLBACK_BODY_TYPE=application/x-www-form-urlencoded # 微信公众号(网页授权 OAuth2) WECHAT_OA_APP_ID=replace-with-oa-appid diff --git a/cmd/api/main.go b/cmd/api/main.go index cc5917d..bc7eb7a 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -11,8 +11,8 @@ import ( adminmodule "wx_service/internal/admin" authhandler "wx_service/internal/common/auth/handler" authservice "wx_service/internal/common/auth/service" - qiniuhandler "wx_service/internal/common/qiniu/handler" - qiniuservice "wx_service/internal/common/qiniu/service" + uploadhandler "wx_service/internal/common/upload/handler" + uploadservice "wx_service/internal/common/upload/service" rediscache "wx_service/internal/common/redis/cache" redisservice "wx_service/internal/common/redis/service" oahandler "wx_service/internal/common/wechat_official/handler" @@ -123,8 +123,8 @@ func main() { redeemCodeService := membershipservice.NewRedeemCodeService(database.DB, config.AppConfig.Admin.Token) redeemCodeHandler := membershiphandler.NewRedeemCodeHandler(redeemCodeService) - qiniuService := qiniuservice.NewQiniuService(config.AppConfig.Qiniu) - uploadHandler := qiniuhandler.NewUploadHandler(qiniuService) + uploadSvc := uploadservice.NewUploadService(config.AppConfig.OSS) + uploadHandler := uploadhandler.NewUploadHandler(uploadSvc) oaService := oaservice.NewWeChatOAService(config.AppConfig.WeChatOA) oaOAuthHandler := oahandler.NewOAuthHandler(oaService) diff --git a/config/config.go b/config/config.go index d6b5d3b..28957ed 100755 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,7 @@ type Config struct { ShortVideo ShortVideoConfig AI AIConfig Admin AdminConfig - Qiniu QiniuConfig + OSS OSSConfig WeChatOA WeChatOfficialConfig Redis RedisConfig } @@ -71,9 +71,9 @@ type AdminConfig struct { DefaultPassword string } -// QiniuConfig 用于七牛云(Kodo)直传相关配置。 -// 前端通常会向后端请求 upload token,然后直传文件到七牛。 -type QiniuConfig struct { +// OSSConfig 用于阿里云 OSS 直传相关配置。 +// 前端通常会向后端请求上传凭证,然后直传文件到 OSS。 +type OSSConfig struct { AccessKey string SecretKey string Bucket string @@ -151,17 +151,17 @@ func LoadConfig() { DefaultUsername: getEnv("ADMIN_DEFAULT_USERNAME", "admin"), DefaultPassword: getEnv("ADMIN_DEFAULT_PASSWORD", "admin123"), }, - Qiniu: QiniuConfig{ - AccessKey: getEnv("QINIU_ACCESS_KEY", ""), - SecretKey: getEnv("QINIU_SECRET_KEY", ""), - Bucket: getEnv("QINIU_BUCKET", ""), - UploadURL: getEnv("QINIU_UPLOAD_URL", "https://upload.qiniup.com"), - CDNDomain: getEnv("QINIU_CDN_DOMAIN", ""), - KeyPrefix: getEnv("QINIU_KEY_PREFIX", "uploads/"), - TokenExpireSeconds: getEnvAsInt("QINIU_TOKEN_EXPIRE_SECONDS", 300), - CallbackURL: getEnv("QINIU_CALLBACK_URL", ""), - CallbackBody: getEnv("QINIU_CALLBACK_BODY", "key=$(key)&hash=$(etag)&fsize=$(fsize)&mimeType=$(mimeType)"), - CallbackBodyType: getEnv("QINIU_CALLBACK_BODY_TYPE", "application/x-www-form-urlencoded"), + OSS: OSSConfig{ + AccessKey: getEnv("OSS_ACCESS_KEY", ""), + SecretKey: getEnv("OSS_SECRET_KEY", ""), + Bucket: getEnv("OSS_BUCKET", ""), + UploadURL: getEnv("OSS_UPLOAD_URL", ""), + CDNDomain: getEnv("OSS_CDN_DOMAIN", ""), + KeyPrefix: getEnv("OSS_KEY_PREFIX", "uploads/"), + TokenExpireSeconds: getEnvAsInt("OSS_TOKEN_EXPIRE_SECONDS", 300), + CallbackURL: getEnv("OSS_CALLBACK_URL", ""), + CallbackBody: getEnv("OSS_CALLBACK_BODY", "key=$(key)&hash=$(etag)&fsize=$(fsize)&mimeType=$(mimeType)"), + CallbackBodyType: getEnv("OSS_CALLBACK_BODY_TYPE", "application/x-www-form-urlencoded"), }, WeChatOA: WeChatOfficialConfig{ AppID: getEnv("WECHAT_OA_APP_ID", ""), diff --git a/docs/common/README.md b/docs/common/README.md index d6fb114..609bc57 100644 --- a/docs/common/README.md +++ b/docs/common/README.md @@ -23,7 +23,7 @@ 除登录接口外,其他接口都需要携带登录后返回的 `session_key`(见:`docs/common/auth.md`)。 -## 上传(七牛直传) +## 上传(阿里云 OSS 直传) - `docs/common/upload_qiniu.md` @@ -38,3 +38,7 @@ ## 自动化部署(非 Docker) - `docs/common/deploy_ci.md` + +## 后端开发规范 + +- `docs/common/backend_convention.md`(编码风格、模块分层、命名约定、接口规范、新增模块流程等) diff --git a/docs/common/backend_convention.md b/docs/common/backend_convention.md new file mode 100644 index 0000000..407d759 --- /dev/null +++ b/docs/common/backend_convention.md @@ -0,0 +1,477 @@ +# 后端开发规范 (wx_service) + +本文档定义 `wx_service` 后端项目的编码规范、架构约定和开发流程,适用于所有后端贡献者。 + +--- + +## 1. 技术栈与环境 + +| 分类 | 技术 | 最低版本 | +|------|------|----------| +| 语言 | Go | 1.23 | +| Web 框架 | Gin | v1.11 | +| ORM | GORM | v1.31 | +| 数据库 | MySQL | 8.x | +| 缓存 | Redis (go-redis v9) | 可选 | +| 鉴权 | JWT (golang-jwt v5) | — | +| 配置 | godotenv | — | + +**开发工具要求**: +- 使用 `gofmt` / `goimports` 格式化代码 +- 推荐 `golangci-lint` 进行静态检查 +- 提交前确保 `go vet ./...` 无警告 + +--- + +## 2. 项目结构 + +``` +wx_service/ +├── cmd/api/main.go # 程序入口(配置加载 → DB 初始化 → 依赖注入 → 路由注册 → 启动) +├── config/ # 配置结构体与加载逻辑 +├── internal/ # 业务代码(不可被外部包导入) +│ ├── admin/ # 管理后台模块 +│ ├── common/ # 公共能力 +│ │ ├── auth/ # 登录认证 +│ │ ├── upload/ # OSS 上传 +│ │ ├── redis/ # Redis 缓存 +│ │ └── wechat_official/ # 公众号 OAuth +│ ├── database/ # 数据库连接与迁移 +│ ├── middleware/ # HTTP 中间件 +│ ├── model/ # 公共数据模型(User、MiniProgram、Response) +│ ├── observability/ # 指标采集与日志 +│ ├── routes/ # 路由注册 +│ └── / # 业务模块(按域拆分) +├── docs/ # 文档 +├── deploy/ # 部署配置 +├── scripts/ # 运维脚本 +├── .env.example # 环境变量模板 +├── Dockerfile # 容器镜像构建 +├── docker-compose.yml # 开发环境依赖 +└── docker-compose.prod.yml # 生产环境编排 +``` + +--- + +## 3. 模块分层架构 + +每个业务模块位于 `internal//`,采用以下分层结构: + +``` +internal// +├── handler/ # HTTP 层 +├── service/ # 业务逻辑层 +├── model/ # 数据模型 +└── repository/ # 数据访问层(可选) +``` + +### 3.1 各层职责 + +| 层 | 职责 | 禁止事项 | +|----|------|----------| +| **handler** | 解析请求参数、调用 service、组装 HTTP 响应 | 不包含业务逻辑、不直接操作数据库 | +| **service** | 实现核心业务规则、编排多表操作、调用外部 API | 不感知 HTTP 层(不依赖 `gin.Context` 进行响应) | +| **model** | 定义 GORM struct、表名、数据校验 | 不包含业务逻辑 | +| **repository** | 封装数据库查询(可选,简单模块可由 service 直接操作 DB) | 不包含业务逻辑 | + +### 3.2 依赖方向 + +``` +handler → service → repository → model + → model +``` + +handler 依赖 service,service 依赖 repository(或直接使用 `*gorm.DB`),所有层共享 model。禁止反向依赖。 + +### 3.3 依赖注入 + +采用构造函数注入,在 `cmd/api/main.go` 中完成装配: + +```go +// service 接收 *gorm.DB 和配置 +svc := someservice.NewSomeService(database.DB, config.AppConfig.SomeConfig) + +// handler 接收 service +handler := somehandler.NewSomeHandler(svc) + +// 路由注册接收 handler +routes.Register(router, handler, ...) +``` + +--- + +## 4. 命名规范 + +### 4.1 文件命名 + +- 使用 **snake_case**:`smoke_log_service.go`、`auth_handler.go` +- 测试文件以 `_test.go` 结尾:`smoke_log_service_test.go` +- 每个文件聚焦单一职责,避免大文件 + +### 4.2 包命名 + +- 全小写,不使用下划线:`handler`、`service`、`model` +- 导入别名使用有意义的缩写:`smokeservice`、`rmhandler` + +### 4.3 结构体与函数 + +- 导出类型使用 PascalCase:`SmokeHandler`、`SmokeLogService` +- 构造函数统一使用 `New` 前缀:`NewSmokeHandler` +- 请求结构体使用小写开头(非导出):`createSmokeLogRequest` +- 常量使用 camelCase 或 PascalCase(视作用域定) + +### 4.4 数据库相关 + +- 表名使用 **snake_case**:`fa_smoke_log`、`marketing_categories` +- 模型通过 `TableName()` 方法显式指定表名 +- 字段标签使用 `gorm:"column:xxx"` 明确列名 + +--- + +## 5. HTTP 接口规范 + +### 5.1 响应格式 + +所有接口统一使用 `model.Response` 结构: + +```go +// 成功 +c.JSON(http.StatusOK, model.Success(data)) + +// 失败 +c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "参数错误描述")) +``` + +响应 JSON 结构: + +```json +{ + "code": 200, + "message": "success", + "data": {} +} +``` + +### 5.2 HTTP 状态码使用 + +| 状态码 | 场景 | +|--------|------| +| 200 | 成功(包括空结果集) | +| 400 | 请求参数校验失败 | +| 401 | 未登录或 Token 过期 | +| 403 | 无权限或业务限制(如额度用尽) | +| 404 | 资源不存在 | +| 500 | 服务端内部错误 | +| 502 | 第三方服务调用失败 | +| 503 | 关键配置缺失导致服务不可用 | + +### 5.3 路由规范 + +- RESTful 风格:`GET` 获取、`POST` 创建、`PUT` 更新、`DELETE` 删除 +- 路由前缀: + - `/api/v1` — 主版本 API + - `/api/v2` — 新版本模块 + - `/api/expiry` — 保质期独立域 + - `/api/admin` — 管理后台 +- 路由注册集中在 `internal/routes/` 目录下 +- 每个模块创建独立的 `registerRoutes` 函数 + +### 5.4 认证 + +- 用户认证:`Bearer `,通过 `AuthMiddleware` + `RequireUserMiddleware` 处理 +- 管理后台:`X-Admin-Token` 头或 JWT +- 公开接口不挂载鉴权中间件 +- handler 中通过 `middleware.MustCurrentUser(c)` 获取当前用户 + +### 5.5 分页 + +列表接口统一使用以下参数和返回结构: + +``` +// 请求参数 +?page=1&page_size=20 + +// 响应 +{ + "code": 200, + "message": "success", + "data": { + "items": [...], + "total": 100, + "page": 1, + "page_size": 20 + } +} +``` + +--- + +## 6. 数据库规范 + +### 6.1 GORM 模型 + +```go +type SomeModel struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + Name string `gorm:"size:100;not null" json:"name"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` +} + +func (SomeModel) TableName() string { + return "some_models" +} +``` + +### 6.2 迁移 + +- 开发阶段使用 `AutoMigrate`,在 `cmd/api/main.go` 中注册新模型 +- 生产环境建议手动迁移,SQL 参考放在 `docs/sql/` 目录 + +### 6.3 查询 + +- 优先使用 GORM 链式查询,避免裸 SQL +- 复杂报表查询可使用 `db.Raw()`,但需添加注释说明 +- 列表查询必须加分页限制(`Limit` + `Offset`) +- 敏感查询加 `context.Context` 传递超时控制 + +### 6.4 事务 + +涉及多表写入的操作使用事务: + +```go +err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 多步操作 + return nil +}) +``` + +--- + +## 7. 错误处理 + +### 7.1 Service 层 + +- 定义领域错误变量:`var ErrSmokeLogNotFound = errors.New("smoke log not found")` +- Service 返回具体的错误类型,handler 据此决定 HTTP 状态码 +- 内部错误使用 `fmt.Errorf("xxx: %w", err)` 包装 + +### 7.2 Handler 层 + +- 用 `errors.Is()` 判断 service 返回的错误类型 +- 用户可见的错误信息使用中文 +- 内部错误记录日志后返回通用提示:"xxx失败,请稍后重试" + +```go +record, err := h.service.GetByID(ctx, userID, id) +if err != nil { + if errors.Is(err, service.ErrNotFound) { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "记录不存在")) + return + } + log.Printf("GetByID failed: %v", err) + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "查询失败,请稍后重试")) + return +} +``` + +--- + +## 8. 配置管理 + +### 8.1 环境变量 + +- 所有配置通过 `.env` 文件加载,使用 `godotenv` +- 新增配置项必须同步更新 `.env.example` +- 敏感信息(密钥、密码)不可提交到代码仓库 + +### 8.2 配置结构 + +配置统一定义在 `config/config.go` 的结构体中: + +```go +type Config struct { + Server ServerConfig + Database DatabaseConfig + JWT JWTConfig + // 按需扩展... +} +``` + +### 8.3 可选功能降级 + +部分功能(如 Redis、额外数据库)可选启用。启动时检测配置是否存在,缺失则打印日志并禁用相关接口,不阻塞服务启动: + +```go +if redisClient == nil { + log.Println("redis disabled: ...") +} +``` + +--- + +## 9. 测试规范 + +### 9.1 文件与组织 + +- 测试文件与被测文件同目录:`smoke_log_service.go` → `smoke_log_service_test.go` +- 使用 Go 标准测试框架 `testing` +- 表驱动测试(table-driven tests)优先 + +### 9.2 命名 + +- 测试函数:`TestFunctionName_Scenario` +- 子测试:`t.Run("scenario description", ...)` + +### 9.3 运行 + +```bash +# 运行全部测试 +go test ./... + +# 运行指定模块测试 +go test ./internal/smoke/service/... + +# 带覆盖率 +go test -cover ./... +``` + +--- + +## 10. 新增业务模块流程 + +以新增"xx功能"模块为例: + +### 第一步:创建模块目录 + +``` +internal/xx/ +├── handler/ +│ └── xx_handler.go +├── service/ +│ └── xx_service.go +└── model/ + └── xx.go +``` + +### 第二步:定义数据模型 + +```go +// internal/xx/model/xx.go +package model + +type XX struct { + ID uint `gorm:"primaryKey" json:"id"` + UserID uint `gorm:"index;not null" json:"user_id"` + // ... +} + +func (XX) TableName() string { return "xx_records" } +``` + +### 第三步:实现 Service + +```go +// internal/xx/service/xx_service.go +package service + +type XXService struct { + db *gorm.DB +} + +func NewXXService(db *gorm.DB) *XXService { + return &XXService{db: db} +} +``` + +### 第四步:实现 Handler + +```go +// internal/xx/handler/xx_handler.go +package handler + +type XXHandler struct { + service *xxservice.XXService +} + +func NewXXHandler(svc *xxservice.XXService) *XXHandler { + return &XXHandler{service: svc} +} +``` + +### 第五步:注册依赖和路由 + +1. 在 `cmd/api/main.go` 中: + - 导入新模块 + - 初始化 service → handler + - 将新模型加入 `AutoMigrate` +2. 在 `internal/routes/` 中新建 `xx_routes.go`,实现 `registerXXRoutes` +3. 在 `routes.go` 的 `Register` 函数中调用 + +### 第六步:编写文档 + +在 `docs/xx/` 下创建: +- `README.md`:模块需求说明 +- `API.md`:接口文档(方法、路径、参数、返回值) + +--- + +## 11. Git 与提交规范 + +### 11.1 分支策略 + +- `main` / `master`:生产分支,推送自动触发部署 +- 功能分支:`feature/` 或 `fix/` + +### 11.2 提交信息格式 + +``` +: <简要描述> + +<可选的详细说明> +``` + +类型约定: + +| 类型 | 说明 | +|------|------| +| feat | 新功能 | +| fix | 修复 Bug | +| refactor | 重构(不影响功能) | +| docs | 文档变更 | +| test | 测试相关 | +| chore | 构建/运维/依赖等杂项 | + +示例:`feat: add quit plan CRUD endpoints` + +### 11.3 提交检查清单 + +- [ ] `go vet ./...` 无警告 +- [ ] `gofmt` / `goimports` 已格式化 +- [ ] 相关测试通过 +- [ ] 新增接口已更新文档 +- [ ] `.env.example` 已同步更新(如有新配置项) + +--- + +## 12. 部署 + +### Docker 方式 + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +### 非 Docker(GitHub Actions) + +推送 `main` 分支自动触发 `.github/workflows/deploy-prod.yml`。 + +### 健康检查 + +- `GET /healthz` → `{"status": "ok"}` +- Docker 健康检查间隔 30s +- Nginx 反代配置在 `deploy/nginx/` 下 + +详细部署文档:`docs/common/deploy_ci.md` diff --git a/docs/common/upload_qiniu.md b/docs/common/upload_qiniu.md index 7eecaf2..bec5e49 100644 --- a/docs/common/upload_qiniu.md +++ b/docs/common/upload_qiniu.md @@ -1,19 +1,19 @@ -# 七牛(Kodo)直传:获取上传凭证 +# 阿里云 OSS 直传:获取上传凭证 -用途:小程序/前端把文件直接上传到七牛(CDN 源站),后端只负责签发上传 token,减少带宽与压力。 +用途:小程序/前端把文件直接上传到阿里云 OSS,后端只负责签发上传凭证,减少带宽与压力。 ## 前置条件 - 已完成登录并拿到 `session_key`(见:`docs/common/auth.md`) -- 已配置 `.env` 中的七牛参数(见:`.env.example`) +- 已配置 `.env` 中的 OSS 参数(见:`.env.example`) - 若需要上传成功回调,请额外配置: - - `QINIU_CALLBACK_URL`(例如:`https://api.example.com/api/v1/common/upload/qiniu/callback`) - - `QINIU_CALLBACK_BODY` - - `QINIU_CALLBACK_BODY_TYPE` + - `OSS_CALLBACK_URL`(例如:`https://api.example.com/api/v1/common/upload/oss/callback`) + - `OSS_CALLBACK_BODY` + - `OSS_CALLBACK_BODY_TYPE` ## 接口 -`POST /api/v1/common/upload/qiniu/token` +`POST /api/v1/common/upload/oss/token` Header: @@ -33,66 +33,70 @@ Content-Type: application/json 说明: - `filename` 仅用于提取文件后缀(例如 `.png`),以便后端生成带后缀的 `key`;不传也可以。 -成功响应示例: +成功响应示例(OSS PostObject 凭证): ```json { "code": 200, "message": "success", "data": { - "token": "xxx", - "key": "uploads/mp_1/user_123/20251231/ab12cd34ef....png", - "upload_url": "https://upload.qiniup.com", - "expire": 1767150000, - "cdn_domain": "https://cdn.example.com" + "key": "uploads/mp_1/user_123/20251231/ab12cd34....png", + "upload_url": "https://bucket.oss-cn-beijing.aliyuncs.com", + "cdn_domain": "https://bucket.oss-cn-beijing.aliyuncs.com", + "oss_access_key_id": "LTAI5t...", + "oss_policy": "base64...", + "oss_signature": "sig..." } } ``` 字段说明: -- `token`:七牛上传凭证(uptoken) - `key`:后端生成的对象 key,上传时必须使用该 key -- `upload_url`:上传入口(表单上传 URL) -- `expire`:token 过期时间(Unix 秒) -- `cdn_domain`:可选;如果配置了,可用 `cdn_domain + "/" + key` 拼出访问 URL +- `upload_url`:OSS PostObject 上传地址 +- `cdn_domain`:CDN 访问域名,可用 `cdn_domain + "/" + key` 拼出访问 URL +- `oss_access_key_id`:OSS AccessKeyId +- `oss_policy`:Base64 编码的 Policy +- `oss_signature`:HMAC-SHA1 签名 ## 使用示例(curl 直传) -1) 先请求 token: +1) 先请求凭证: ```bash -curl -X POST 'http://127.0.0.1:8080/api/v1/common/upload/qiniu/token' \ +curl -X POST 'http://127.0.0.1:8080/api/v1/common/upload/oss/token' \ -H 'Content-Type: application/json' \ -H 'Authorization: Bearer wx-session-key' \ -d '{"filename":"avatar.png"}' ``` -2) 再把文件直传七牛(multipart/form-data): +2) 再把文件直传 OSS(multipart/form-data): ```bash -curl -X POST 'https://upload.qiniup.com' \ - -F "token=上一步返回的 token" \ - -F "key=上一步返回的 key" \ +curl -X POST '' \ + -F "key=<上一步返回的 key>" \ + -F "policy=" \ + -F "OSSAccessKeyId=" \ + -F "Signature=" \ -F "file=@./avatar.png" ``` -七牛成功时会返回 JSON(字段可能因配置不同略有差异),其中一般会包含 `key/hash`。 +OSS 成功时返回 HTTP 204(无 body)。 --- ## 上传回调(服务端) -当配置了 `QINIU_CALLBACK_URL` 后,后端签发的 putPolicy 会包含 callback 参数。七牛上传成功后会回调: +当配置了 `OSS_CALLBACK_URL` 后,上传成功后会回调: -`POST /api/v1/common/upload/qiniu/callback` +`POST /api/v1/common/upload/oss/callback` 说明: -- 该接口无需登录(由七牛服务端调用)。 -- 服务端会校验 `Authorization: QBox ...` 签名。 +- 该接口无需登录(由 OSS 服务端调用)。 +- 服务端会校验 `Authorization` 签名。 - 验签失败返回 `401`,直接拒绝。 -- 当业务处理发生临时异常时可返回 `503`,利用七牛回调重试机制重试。 +- 当业务处理发生临时异常时可返回 `503`,利用回调重试机制重试。 -默认回调体(可通过 `QINIU_CALLBACK_BODY` 调整): +默认回调体(可通过 `OSS_CALLBACK_BODY` 调整): ```txt key=$(key)&hash=$(etag)&fsize=$(fsize)&mimeType=$(mimeType) diff --git a/docs/marketing/README.md b/docs/marketing/README.md index 8681ee2..53de891 100644 --- a/docs/marketing/README.md +++ b/docs/marketing/README.md @@ -124,7 +124,7 @@ web/marketing/ ## 依赖的公共服务 - **鉴权**:复用 `middleware.AuthMiddleware` + `middleware.RequireUserMiddleware` -- **七牛上传**:模板图片和用户 Logo 均通过七牛直传,复用 `common/qiniu` 模块 +- **OSS 上传**:模板图片和用户 Logo 均通过阿里云 OSS 直传,复用 `common/upload` 模块 - **Admin Token**:通过 `X-Admin-Token` 请求头鉴权,Token 来自 `.env` 的 `ADMIN_API_TOKEN` ## Web 管理后台 diff --git a/go.mod b/go.mod index af560a8..49105cc 100755 --- a/go.mod +++ b/go.mod @@ -1,8 +1,6 @@ module wx_service -go 1.23.0 - -toolchain go1.24.4 +go 1.25.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -10,7 +8,7 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/joho/godotenv v1.5.1 github.com/redis/go-redis/v9 v9.17.2 - golang.org/x/crypto v0.40.0 + golang.org/x/crypto v0.48.0 gorm.io/driver/mysql v1.6.0 gorm.io/driver/sqlite v1.6.0 gorm.io/gorm v1.31.1 @@ -47,11 +45,12 @@ require ( github.com/ugorji/go/codec v1.3.0 // indirect go.uber.org/mock v0.5.0 // indirect golang.org/x/arch v0.20.0 // indirect - golang.org/x/mod v0.25.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.35.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.34.0 // indirect + golang.org/x/image v0.38.0 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/net v0.50.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.41.0 // indirect + golang.org/x/text v0.35.0 // indirect + golang.org/x/tools v0.42.0 // indirect google.golang.org/protobuf v1.36.9 // indirect ) diff --git a/go.sum b/go.sum index 0281eb3..6e67720 100644 --- a/go.sum +++ b/go.sum @@ -94,19 +94,35 @@ golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= +golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/image v0.38.0 h1:5l+q+Y9JDC7mBOMjo4/aPhMDcxEptsX+Tt3GgRQRPuE= +golang.org/x/image v0.38.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= +golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= +golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/common/auth/handler/auth_handler.go b/internal/common/auth/handler/auth_handler.go index 2beab96..f0b33a8 100644 --- a/internal/common/auth/handler/auth_handler.go +++ b/internal/common/auth/handler/auth_handler.go @@ -2,6 +2,7 @@ package handler import ( "errors" + "log" "net/http" "github.com/gin-gonic/gin" @@ -107,3 +108,43 @@ func (h *AuthHandler) LoginWithWeChat(c *gin.Context) { "mini_program": miniProgramPayload, })) } + +type devLoginRequest struct { + MiniProgramID uint `json:"mini_program_id"` +} + +// DevLogin 仅在非 release 模式下可用,用于 H5 开发调试。 +func (h *AuthHandler) DevLogin(c *gin.Context) { + if gin.Mode() == gin.ReleaseMode { + c.JSON(http.StatusNotFound, model.Error(http.StatusNotFound, "not found")) + return + } + + var req devLoginRequest + _ = c.ShouldBindJSON(&req) + if req.MiniProgramID == 0 { + req.MiniProgramID = 3 + } + + result, err := h.authService.DevLogin(c.Request.Context(), req.MiniProgramID) + if err != nil { + log.Printf("[dev_login] error: %v", err) + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "dev login failed: "+err.Error())) + return + } + + c.JSON(http.StatusOK, model.Success(gin.H{ + "user": gin.H{ + "id": result.User.ID, + "mini_program_id": result.User.MiniProgramID, + "open_id": result.User.OpenID, + "nickname": result.User.NickName, + "avatar_url": result.User.AvatarURL, + }, + "session_key": result.SessionKey, + "mini_program": gin.H{ + "id": result.MiniProgram.ID, + "name": result.MiniProgram.Name, + }, + })) +} diff --git a/internal/common/auth/service/auth_service.go b/internal/common/auth/service/auth_service.go index 92353df..e37b89c 100644 --- a/internal/common/auth/service/auth_service.go +++ b/internal/common/auth/service/auth_service.go @@ -147,6 +147,51 @@ func (s *AuthService) LoginWithCode(ctx context.Context, req LoginRequest) (*Log return result, nil } +// DevLogin 开发模式专用:创建或查找测试用户,无需微信授权。 +func (s *AuthService) DevLogin(ctx context.Context, miniProgramID uint) (*LoginResult, error) { + if miniProgramID == 0 { + return nil, ErrMiniProgramRequired + } + + miniProgram, err := s.miniProgramSvc.GetByID(ctx, miniProgramID) + if err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, ErrMiniProgramNotFound + } + return nil, fmt.Errorf("load mini program: %w", err) + } + + const devOpenID = "dev_test_user" + sessionKey := fmt.Sprintf("dev_session_%d", miniProgramID) + + tx := s.db.WithContext(ctx) + var user model.User + err = tx.Where("mini_program_id = ? AND open_id = ?", miniProgramID, devOpenID).First(&user).Error + if errors.Is(err, gorm.ErrRecordNotFound) { + user = model.User{ + MiniProgramID: miniProgramID, + OpenID: devOpenID, + NickName: "开发测试用户", + AvatarURL: defaultAvatarURL, + SessionKey: sessionKey, + } + if err := tx.Create(&user).Error; err != nil { + return nil, fmt.Errorf("create dev user: %w", err) + } + } else if err != nil { + return nil, fmt.Errorf("query dev user: %w", err) + } else { + tx.Model(&user).Update("session_key", sessionKey) + user.SessionKey = sessionKey + } + + return &LoginResult{ + User: &user, + SessionKey: sessionKey, + MiniProgram: miniProgram, + }, nil +} + func (s *AuthService) getSmokeMode(ctx context.Context, uid int) (string, error) { var profile smokemodel.SmokeUserProfile err := s.db.WithContext(ctx). diff --git a/internal/common/imageutil/resize.go b/internal/common/imageutil/resize.go new file mode 100644 index 0000000..c5d9d87 --- /dev/null +++ b/internal/common/imageutil/resize.go @@ -0,0 +1,72 @@ +package imageutil + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "io" + "strings" + + "golang.org/x/image/draw" +) + +type ResizeOptions struct { + MaxWidth int + Quality int // JPEG quality 1-100 +} + +func DefaultResizeOptions() ResizeOptions { + return ResizeOptions{MaxWidth: 800, Quality: 80} +} + +// GenerateThumbnail reads image data, resizes if wider than MaxWidth, and +// re-encodes as JPEG. Returns the thumbnail bytes and whether the image +// was actually resized (false if already small enough but still re-encoded). +func GenerateThumbnail(data []byte, contentType string, opts ResizeOptions) ([]byte, error) { + if opts.MaxWidth <= 0 { + opts.MaxWidth = 800 + } + if opts.Quality <= 0 || opts.Quality > 100 { + opts.Quality = 80 + } + + src, err := decodeImage(bytes.NewReader(data), contentType) + if err != nil { + return nil, fmt.Errorf("decode image: %w", err) + } + + bounds := src.Bounds() + srcW := bounds.Dx() + srcH := bounds.Dy() + + dstW := srcW + dstH := srcH + if srcW > opts.MaxWidth { + dstW = opts.MaxWidth + dstH = srcH * opts.MaxWidth / srcW + } + + dst := image.NewRGBA(image.Rect(0, 0, dstW, dstH)) + draw.BiLinear.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + var buf bytes.Buffer + if err := jpeg.Encode(&buf, dst, &jpeg.Options{Quality: opts.Quality}); err != nil { + return nil, fmt.Errorf("encode jpeg: %w", err) + } + return buf.Bytes(), nil +} + +func decodeImage(r io.Reader, contentType string) (image.Image, error) { + ct := strings.ToLower(contentType) + switch { + case strings.Contains(ct, "png"): + return png.Decode(r) + case strings.Contains(ct, "jpeg"), strings.Contains(ct, "jpg"): + return jpeg.Decode(r) + default: + img, _, err := image.Decode(r) + return img, err + } +} diff --git a/internal/common/qiniu/handler/upload_handler.go b/internal/common/upload/handler/upload_handler.go similarity index 66% rename from internal/common/qiniu/handler/upload_handler.go rename to internal/common/upload/handler/upload_handler.go index 9e20066..3ea3583 100644 --- a/internal/common/qiniu/handler/upload_handler.go +++ b/internal/common/upload/handler/upload_handler.go @@ -18,25 +18,24 @@ import ( "wx_service/config" oss "wx_service/internal/common/oss" - qiniuservice "wx_service/internal/common/qiniu/service" + uploadservice "wx_service/internal/common/upload/service" "wx_service/internal/middleware" "wx_service/internal/model" ) type UploadHandler struct { - qiniuService *qiniuservice.QiniuService + uploadService *uploadservice.UploadService } -func NewUploadHandler(qiniuService *qiniuservice.QiniuService) *UploadHandler { - return &UploadHandler{qiniuService: qiniuService} +func NewUploadHandler(uploadService *uploadservice.UploadService) *UploadHandler { + return &UploadHandler{uploadService: uploadService} } -type qiniuTokenRequest struct { - // filename 用于保留文件后缀(可选),例如:"a.png"、"video.mp4" +type uploadTokenRequest struct { Filename string `json:"filename"` } -type qiniuCallbackPayload struct { +type callbackPayload struct { Key string `json:"key"` Hash string `json:"hash"` Fsize int64 `json:"fsize"` @@ -56,15 +55,14 @@ type uploadTokenResponse struct { var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,10}$`) -// QiniuToken 返回直传所需的 token/key/upload_url;CDN 为阿里云 OSS 时返回 OSS PostObject 凭证。 -// 建议放在鉴权后:用当前登录用户生成 key,避免前端写入任意路径。 -func (h *UploadHandler) QiniuToken(c *gin.Context) { +// GetUploadToken 返回直传所需的凭证;CDN 为阿里云 OSS 时返回 OSS PostObject 凭证。 +func (h *UploadHandler) GetUploadToken(c *gin.Context) { user := middleware.MustCurrentUser(c) - var req qiniuTokenRequest + var req uploadTokenRequest _ = c.ShouldBindJSON(&req) - cfg := config.AppConfig.Qiniu + cfg := config.AppConfig.OSS cdnDomain := strings.TrimSpace(cfg.CDNDomain) if oss.IsOSSDomain(cdnDomain) && cfg.AccessKey != "" && cfg.SecretKey != "" && cfg.Bucket != "" { @@ -99,10 +97,10 @@ func (h *UploadHandler) QiniuToken(c *gin.Context) { return } - token, err := h.qiniuService.CreateUploadToken(user.MiniProgramID, user.ID, req.Filename) + token, err := h.uploadService.CreateUploadToken(user.MiniProgramID, user.ID, req.Filename) if err != nil { - if errors.Is(err, qiniuservice.ErrQiniuNotConfigured) { - c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置七牛上传服务,请联系管理员")) + if errors.Is(err, uploadservice.ErrUploadNotConfigured) { + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置上传服务,请联系管理员")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败,请稍后重试")) @@ -111,28 +109,25 @@ func (h *UploadHandler) QiniuToken(c *gin.Context) { c.JSON(http.StatusOK, model.Success(token)) } -// QiniuCallback 处理七牛上传回调(无需登录),通过签名验签确保来源可信。 -// 说明: -// - 验签失败返回 401(非可信请求,直接拒绝) -// - 业务处理临时失败返回 503(触发七牛重试) -func (h *UploadHandler) QiniuCallback(c *gin.Context) { +// UploadCallback 处理上传回调(无需登录),通过签名验签确保来源可信。 +func (h *UploadHandler) UploadCallback(c *gin.Context) { rawBody, err := io.ReadAll(c.Request.Body) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "读取回调内容失败")) return } - if err := h.qiniuService.VerifyCallbackSignature(c.Request, rawBody); err != nil { + if err := h.uploadService.VerifyCallbackSignature(c.Request, rawBody); err != nil { switch { - case errors.Is(err, qiniuservice.ErrQiniuNotConfigured): - c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "七牛服务未配置")) + case errors.Is(err, uploadservice.ErrUploadNotConfigured): + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "上传服务未配置")) default: c.JSON(http.StatusUnauthorized, model.Error(http.StatusUnauthorized, "回调验签失败")) } return } - payload, err := parseQiniuCallbackPayload(c.ContentType(), rawBody) + payload, err := parseCallbackPayload(c.ContentType(), rawBody) if err != nil { c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "回调内容格式错误")) return @@ -142,24 +137,23 @@ func (h *UploadHandler) QiniuCallback(c *gin.Context) { return } - // 当前阶段先记录日志。后续如接入 DB/任务系统,处理失败可保持 503 以触发七牛重试。 - log.Printf("[qiniu_callback] key=%s hash=%s fsize=%d mimeType=%s", payload.Key, payload.Hash, payload.Fsize, payload.MimeType) + log.Printf("[upload_callback] key=%s hash=%s fsize=%d mimeType=%s", payload.Key, payload.Hash, payload.Fsize, payload.MimeType) c.JSON(http.StatusOK, model.Success(gin.H{"ok": true})) } -func parseQiniuCallbackPayload(contentType string, raw []byte) (qiniuCallbackPayload, error) { - var payload qiniuCallbackPayload +func parseCallbackPayload(contentType string, raw []byte) (callbackPayload, error) { + var payload callbackPayload trimmed := strings.TrimSpace(strings.ToLower(contentType)) if strings.Contains(trimmed, "application/json") { if err := json.Unmarshal(raw, &payload); err != nil { - return qiniuCallbackPayload{}, err + return callbackPayload{}, err } return payload, nil } values, err := url.ParseQuery(string(raw)) if err != nil { - return qiniuCallbackPayload{}, err + return callbackPayload{}, err } payload.Key = strings.TrimSpace(values.Get("key")) payload.Hash = strings.TrimSpace(values.Get("hash")) @@ -167,7 +161,7 @@ func parseQiniuCallbackPayload(contentType string, raw []byte) (qiniuCallbackPay if rawFsize := strings.TrimSpace(values.Get("fsize")); rawFsize != "" { fsize, parseErr := strconv.ParseInt(rawFsize, 10, 64) if parseErr != nil { - return qiniuCallbackPayload{}, parseErr + return callbackPayload{}, parseErr } payload.Fsize = fsize } diff --git a/internal/common/qiniu/handler/upload_handler_test.go b/internal/common/upload/handler/upload_handler_test.go similarity index 62% rename from internal/common/qiniu/handler/upload_handler_test.go rename to internal/common/upload/handler/upload_handler_test.go index 2683665..2a4aa15 100644 --- a/internal/common/qiniu/handler/upload_handler_test.go +++ b/internal/common/upload/handler/upload_handler_test.go @@ -12,25 +12,25 @@ import ( "github.com/gin-gonic/gin" "wx_service/config" - qiniuservice "wx_service/internal/common/qiniu/service" + uploadservice "wx_service/internal/common/upload/service" ) -func TestQiniuCallbackSuccess(t *testing.T) { +func TestUploadCallbackSuccess(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) - cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} - h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + cfg := config.OSSConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(uploadservice.NewUploadService(cfg)) body := "key=uploads/test.png&hash=abc&fsize=12&mimeType=image%2Fpng" - req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/oss/callback", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signQiniu(req.URL.Path+"\n"+body, cfg.SecretKey)) + req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signCallback(req.URL.Path+"\n"+body, cfg.SecretKey)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req - h.QiniuCallback(c) + h.UploadCallback(c) if w.Code != http.StatusOK { t.Fatalf("status=%d, want=200, body=%s", w.Code, w.Body.String()) @@ -40,63 +40,63 @@ func TestQiniuCallbackSuccess(t *testing.T) { } } -func TestQiniuCallbackInvalidSignature(t *testing.T) { +func TestUploadCallbackInvalidSignature(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) - cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} - h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + cfg := config.OSSConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(uploadservice.NewUploadService(cfg)) body := "key=uploads/test.png&hash=abc" - req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/oss/callback", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req.Header.Set("Authorization", "QBox ak-test:bad-sign") w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req - h.QiniuCallback(c) + h.UploadCallback(c) if w.Code != http.StatusUnauthorized { t.Fatalf("status=%d, want=401, body=%s", w.Code, w.Body.String()) } } -func TestQiniuCallbackMissingKey(t *testing.T) { +func TestUploadCallbackMissingKey(t *testing.T) { t.Parallel() gin.SetMode(gin.TestMode) - cfg := config.QiniuConfig{AccessKey: "ak-test", SecretKey: "sk-test"} - h := NewUploadHandler(qiniuservice.NewQiniuService(cfg)) + cfg := config.OSSConfig{AccessKey: "ak-test", SecretKey: "sk-test"} + h := NewUploadHandler(uploadservice.NewUploadService(cfg)) body := "hash=abc&fsize=12" - req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/qiniu/callback", strings.NewReader(body)) + req := httptest.NewRequest(http.MethodPost, "/api/v1/common/upload/oss/callback", strings.NewReader(body)) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signQiniu(req.URL.Path+"\n"+body, cfg.SecretKey)) + req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+signCallback(req.URL.Path+"\n"+body, cfg.SecretKey)) w := httptest.NewRecorder() c, _ := gin.CreateTestContext(w) c.Request = req - h.QiniuCallback(c) + h.UploadCallback(c) if w.Code != http.StatusBadRequest { t.Fatalf("status=%d, want=400, body=%s", w.Code, w.Body.String()) } } -func TestParseQiniuCallbackPayloadJSON(t *testing.T) { +func TestParseCallbackPayloadJSON(t *testing.T) { t.Parallel() raw := []byte(`{"key":"uploads/test.png","hash":"abc","fsize":321,"mimeType":"image/png"}`) - got, err := parseQiniuCallbackPayload("application/json", raw) + got, err := parseCallbackPayload("application/json", raw) if err != nil { - t.Fatalf("parseQiniuCallbackPayload: %v", err) + t.Fatalf("parseCallbackPayload: %v", err) } if got.Key != "uploads/test.png" || got.Hash != "abc" || got.Fsize != 321 { t.Fatalf("unexpected payload: %+v", got) } } -func signQiniu(signing, secret string) string { +func signCallback(signing, secret string) string { mac := hmac.New(sha1.New, []byte(secret)) _, _ = mac.Write([]byte(signing)) return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(mac.Sum(nil)) diff --git a/internal/common/qiniu/service/qiniu_service.go b/internal/common/upload/service/upload_service.go similarity index 68% rename from internal/common/qiniu/service/qiniu_service.go rename to internal/common/upload/service/upload_service.go index a02d169..8d2b5c2 100644 --- a/internal/common/qiniu/service/qiniu_service.go +++ b/internal/common/upload/service/upload_service.go @@ -19,20 +19,20 @@ import ( ) var ( - ErrQiniuNotConfigured = errors.New("qiniu is not configured") - ErrQiniuCallbackUnauthorized = errors.New("qiniu callback unauthorized") - ErrQiniuCallbackInvalidHeader = errors.New("qiniu callback authorization header is invalid") + ErrUploadNotConfigured = errors.New("oss upload is not configured") + ErrCallbackUnauthorized = errors.New("upload callback unauthorized") + ErrCallbackInvalidHeader = errors.New("upload callback authorization header is invalid") ) -type QiniuService struct { - cfg config.QiniuConfig +type UploadService struct { + cfg config.OSSConfig } -func NewQiniuService(cfg config.QiniuConfig) *QiniuService { - return &QiniuService{cfg: cfg} +func NewUploadService(cfg config.OSSConfig) *UploadService { + return &UploadService{cfg: cfg} } -type QiniuUploadToken struct { +type UploadToken struct { Token string `json:"token"` Key string `json:"key"` UploadURL string `json:"upload_url"` @@ -42,9 +42,9 @@ type QiniuUploadToken struct { var extPattern = regexp.MustCompile(`^\.[a-z0-9]{1,10}$`) -func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filename string) (QiniuUploadToken, error) { +func (s *UploadService) CreateUploadToken(miniProgramID uint, userID uint, filename string) (UploadToken, error) { if s.cfg.AccessKey == "" || s.cfg.SecretKey == "" || s.cfg.Bucket == "" { - return QiniuUploadToken{}, ErrQiniuNotConfigured + return UploadToken{}, ErrUploadNotConfigured } expireSeconds := s.cfg.TokenExpireSeconds @@ -60,11 +60,9 @@ func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filena randomHex, err := randomHex(16) if err != nil { - return QiniuUploadToken{}, fmt.Errorf("generate random key: %w", err) + return UploadToken{}, fmt.Errorf("generate random key: %w", err) } - // 统一由后端生成 key,避免前端随意写入任意路径。 - // 这里按“业务前缀/小程序/用户/日期/随机名”组织,便于后期排查与管理。 keyPrefix := strings.Trim(s.cfg.KeyPrefix, "/") key := fmt.Sprintf("%s/mp_%d/user_%d/%s/%s%s", keyPrefix, @@ -76,10 +74,8 @@ func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filena ) putPolicy := map[string]interface{}{ - // scope = ":" 表示只允许写入指定 key(更安全) - "scope": fmt.Sprintf("%s:%s", s.cfg.Bucket, key), - "deadline": expireAt, - // 上传完成后返回给前端的 JSON(七牛会做变量替换) + "scope": fmt.Sprintf("%s:%s", s.cfg.Bucket, key), + "deadline": expireAt, "returnBody": `{"key":"$(key)","hash":"$(etag)","fsize":$(fsize),"mimeType":"$(mimeType)"}`, } if callbackURL := strings.TrimSpace(s.cfg.CallbackURL); callbackURL != "" { @@ -98,7 +94,7 @@ func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filena policyJSON, err := json.Marshal(putPolicy) if err != nil { - return QiniuUploadToken{}, fmt.Errorf("marshal put policy: %w", err) + return UploadToken{}, fmt.Errorf("marshal put policy: %w", err) } encodedPolicy := urlSafeBase64NoPad(policyJSON) @@ -107,7 +103,7 @@ func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filena token := fmt.Sprintf("%s:%s:%s", s.cfg.AccessKey, encodedSign, encodedPolicy) - return QiniuUploadToken{ + return UploadToken{ Token: token, Key: key, UploadURL: s.cfg.UploadURL, @@ -116,38 +112,37 @@ func (s *QiniuService) CreateUploadToken(miniProgramID uint, userID uint, filena }, nil } -func (s *QiniuService) VerifyCallbackSignature(req *http.Request, rawBody []byte) error { +func (s *UploadService) VerifyCallbackSignature(req *http.Request, rawBody []byte) error { if s.cfg.AccessKey == "" || s.cfg.SecretKey == "" { - return ErrQiniuNotConfigured + return ErrUploadNotConfigured } authHeader := strings.TrimSpace(req.Header.Get("Authorization")) if authHeader == "" { - return ErrQiniuCallbackInvalidHeader + return ErrCallbackInvalidHeader } parts := strings.SplitN(authHeader, " ", 2) if len(parts) != 2 { - return ErrQiniuCallbackInvalidHeader + return ErrCallbackInvalidHeader } scheme := strings.TrimSpace(parts[0]) if !strings.EqualFold(scheme, "QBox") { - // 七牛上传回调使用 QBox;其它 scheme 视为非法。 - return ErrQiniuCallbackInvalidHeader + return ErrCallbackInvalidHeader } token := strings.TrimSpace(parts[1]) tokenParts := strings.SplitN(token, ":", 2) if len(tokenParts) != 2 { - return ErrQiniuCallbackInvalidHeader + return ErrCallbackInvalidHeader } accessKey := strings.TrimSpace(tokenParts[0]) providedSign := strings.TrimSpace(tokenParts[1]) if accessKey == "" || providedSign == "" { - return ErrQiniuCallbackInvalidHeader + return ErrCallbackInvalidHeader } if accessKey != s.cfg.AccessKey { - return ErrQiniuCallbackUnauthorized + return ErrCallbackUnauthorized } signing := req.URL.Path @@ -159,7 +154,7 @@ func (s *QiniuService) VerifyCallbackSignature(req *http.Request, rawBody []byte expected := urlSafeBase64NoPad(hmacSHA1([]byte(s.cfg.SecretKey), []byte(signing))) if !hmac.Equal([]byte(providedSign), []byte(expected)) { - return ErrQiniuCallbackUnauthorized + return ErrCallbackUnauthorized } return nil } diff --git a/internal/common/qiniu/service/qiniu_service_test.go b/internal/common/upload/service/upload_service_test.go similarity index 82% rename from internal/common/qiniu/service/qiniu_service_test.go rename to internal/common/upload/service/upload_service_test.go index 79c5f83..62ac6d2 100644 --- a/internal/common/qiniu/service/qiniu_service_test.go +++ b/internal/common/upload/service/upload_service_test.go @@ -13,13 +13,13 @@ import ( func TestCreateUploadTokenWithCallbackPolicy(t *testing.T) { t.Parallel() - svc := NewQiniuService(config.QiniuConfig{ + svc := NewUploadService(config.OSSConfig{ AccessKey: "ak-test", SecretKey: "sk-test", Bucket: "bucket-test", - UploadURL: "https://upload.qiniup.com", + UploadURL: "https://bucket.oss-cn-beijing.aliyuncs.com", KeyPrefix: "uploads/", - CallbackURL: "https://api.example.com/api/v1/common/upload/qiniu/callback", + CallbackURL: "https://api.example.com/api/v1/common/upload/oss/callback", CallbackBody: "key=$(key)&hash=$(etag)", CallbackBodyType: "application/x-www-form-urlencoded", }) @@ -44,7 +44,7 @@ func TestCreateUploadTokenWithCallbackPolicy(t *testing.T) { t.Fatalf("unmarshal policy: %v", err) } - if got, _ := policy["callbackUrl"].(string); got != "https://api.example.com/api/v1/common/upload/qiniu/callback" { + if got, _ := policy["callbackUrl"].(string); got != "https://api.example.com/api/v1/common/upload/oss/callback" { t.Fatalf("callbackUrl=%q, want callback endpoint", got) } if got, _ := policy["callbackBody"].(string); got != "key=$(key)&hash=$(etag)" { @@ -58,14 +58,14 @@ func TestCreateUploadTokenWithCallbackPolicy(t *testing.T) { func TestVerifyCallbackSignature(t *testing.T) { t.Parallel() - cfg := config.QiniuConfig{ + cfg := config.OSSConfig{ AccessKey: "ak-test", SecretKey: "sk-test", } - svc := NewQiniuService(cfg) + svc := NewUploadService(cfg) body := []byte("key=uploads/test.png&hash=abc") - req := httptest.NewRequest("POST", "http://example.com/api/v1/common/upload/qiniu/callback?x=1", strings.NewReader(string(body))) + req := httptest.NewRequest("POST", "http://example.com/api/v1/common/upload/oss/callback?x=1", strings.NewReader(string(body))) signing := req.URL.Path + "?" + req.URL.RawQuery + "\n" + string(body) sign := urlSafeBase64NoPad(hmacSHA1([]byte(cfg.SecretKey), []byte(signing))) req.Header.Set("Authorization", "QBox "+cfg.AccessKey+":"+sign) @@ -78,21 +78,21 @@ func TestVerifyCallbackSignature(t *testing.T) { func TestVerifyCallbackSignatureInvalid(t *testing.T) { t.Parallel() - cfg := config.QiniuConfig{ + cfg := config.OSSConfig{ AccessKey: "ak-test", SecretKey: "sk-test", } - svc := NewQiniuService(cfg) + svc := NewUploadService(cfg) body := []byte("key=uploads/test.png&hash=abc") - req := httptest.NewRequest("POST", "http://example.com/api/v1/common/upload/qiniu/callback", strings.NewReader(string(body))) + req := httptest.NewRequest("POST", "http://example.com/api/v1/common/upload/oss/callback", strings.NewReader(string(body))) req.Header.Set("Authorization", "QBox ak-test:bad-sign") err := svc.VerifyCallbackSignature(req, body) if err == nil { t.Fatalf("expected signature verification error") } - if err != ErrQiniuCallbackUnauthorized { - t.Fatalf("error=%v, want=%v", err, ErrQiniuCallbackUnauthorized) + if err != ErrCallbackUnauthorized { + t.Fatalf("error=%v, want=%v", err, ErrCallbackUnauthorized) } } diff --git a/internal/marketing/handler/download_handler.go b/internal/marketing/handler/download_handler.go index c5ffcdc..b30196e 100644 --- a/internal/marketing/handler/download_handler.go +++ b/internal/marketing/handler/download_handler.go @@ -1,18 +1,31 @@ package handler import ( + "bytes" "errors" + "fmt" + "io" + "log" + "mime/multipart" "net/http" + "path" + "regexp" + "strings" + "time" "github.com/gin-gonic/gin" "wx_service/config" - qiniuservice "wx_service/internal/common/qiniu/service" + "wx_service/internal/common/imageutil" + oss "wx_service/internal/common/oss" + uploadservice "wx_service/internal/common/upload/service" "wx_service/internal/marketing/service" "wx_service/internal/middleware" "wx_service/internal/model" ) +var adminExtPattern = regexp.MustCompile(`^\.[a-z0-9]{1,10}$`) + type DownloadHandler struct { svc *service.DownloadService } @@ -93,19 +106,69 @@ func (h *DownloadHandler) AdminStats(c *gin.Context) { c.JSON(http.StatusOK, model.Success(stats)) } -type adminQiniuTokenRequest struct { +type adminUploadTokenRequest struct { Filename string `json:"filename"` } -func (h *DownloadHandler) AdminQiniuToken(c *gin.Context) { - var req adminQiniuTokenRequest +type adminUploadTokenResponse struct { + Token string `json:"token,omitempty"` + Key string `json:"key"` + UploadURL string `json:"upload_url"` + ExpireAt int64 `json:"expire,omitempty"` + CDNDomain string `json:"cdn_domain,omitempty"` + OSSAccessKey string `json:"oss_access_key_id,omitempty"` + OSSPolicy string `json:"oss_policy,omitempty"` + OSSSignature string `json:"oss_signature,omitempty"` +} + +func (h *DownloadHandler) AdminUploadToken(c *gin.Context) { + var req adminUploadTokenRequest _ = c.ShouldBindJSON(&req) - qiniuSvc := qiniuservice.NewQiniuService(config.AppConfig.Qiniu) - token, err := qiniuSvc.CreateUploadToken(0, 0, req.Filename) + cfg := config.AppConfig.OSS + if cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.Bucket == "" { + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置上传服务")) + return + } + + cdnDomain := strings.TrimSpace(cfg.CDNDomain) + + if oss.IsOSSDomain(cdnDomain) { + ext := path.Ext(req.Filename) + if ext == "" || !adminExtPattern.MatchString(strings.ToLower(ext)) { + ext = ".jpg" + } + keyPrefix := strings.Trim(cfg.KeyPrefix, "/") + key := fmt.Sprintf("%s/admin/%s/%x%s", + keyPrefix, time.Now().Format("20060102"), time.Now().UnixNano()&0xffffffff, ext) + endpoint := oss.ParseOSSEndpoint(cdnDomain) + expireSeconds := cfg.TokenExpireSeconds + if expireSeconds <= 0 { + expireSeconds = 300 + } + policy, signature, err := oss.PostPolicy(cfg.Bucket, endpoint, key, cfg.SecretKey, expireSeconds) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "生成上传凭证失败")) + return + } + uploadURL := oss.UploadHost(cfg.Bucket, endpoint) + cdnHost := "https://" + cfg.Bucket + "." + endpoint + ".aliyuncs.com" + c.JSON(http.StatusOK, model.Success(adminUploadTokenResponse{ + Key: key, + UploadURL: uploadURL, + CDNDomain: cdnHost, + OSSAccessKey: cfg.AccessKey, + OSSPolicy: policy, + OSSSignature: signature, + })) + return + } + + uploadSvc := uploadservice.NewUploadService(cfg) + token, err := uploadSvc.CreateUploadToken(0, 0, req.Filename) if err != nil { - if errors.Is(err, qiniuservice.ErrQiniuNotConfigured) { - c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置七牛上传服务")) + if errors.Is(err, uploadservice.ErrUploadNotConfigured) { + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置上传服务")) return } c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "获取上传凭证失败")) @@ -114,3 +177,124 @@ func (h *DownloadHandler) AdminQiniuToken(c *gin.Context) { c.JSON(http.StatusOK, model.Success(token)) } + +// AdminUploadFile 接收管理后台上传的文件,服务端代理转发到 OSS, +// 同时自动生成一张压缩缩略图一并上传,减少 CDN 占用。 +func (h *DownloadHandler) AdminUploadFile(c *gin.Context) { + file, header, err := c.Request.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "请选择要上传的文件")) + return + } + defer file.Close() + + if header.Size > 10*1024*1024 { + c.JSON(http.StatusBadRequest, model.Error(http.StatusBadRequest, "文件大小不能超过 10MB")) + return + } + + cfg := config.AppConfig.OSS + if cfg.AccessKey == "" || cfg.SecretKey == "" || cfg.Bucket == "" { + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "未配置上传服务")) + return + } + + cdnDomain := strings.TrimSpace(cfg.CDNDomain) + if !oss.IsOSSDomain(cdnDomain) { + c.JSON(http.StatusServiceUnavailable, model.Error(http.StatusServiceUnavailable, "仅支持 OSS 上传")) + return + } + + fileData, err := io.ReadAll(file) + if err != nil { + c.JSON(http.StatusInternalServerError, model.Error(http.StatusInternalServerError, "读取文件失败")) + return + } + + ext := strings.ToLower(path.Ext(header.Filename)) + if ext == "" || !adminExtPattern.MatchString(ext) { + ext = ".jpg" + } + keyPrefix := strings.Trim(cfg.KeyPrefix, "/") + ts := fmt.Sprintf("%x", time.Now().UnixNano()&0xffffffff) + origKey := fmt.Sprintf("%s/admin/%s/%s%s", keyPrefix, time.Now().Format("20060102"), ts, ext) + thumbKey := fmt.Sprintf("%s/admin/%s/%s_thumb.jpg", keyPrefix, time.Now().Format("20060102"), ts) + + endpoint := oss.ParseOSSEndpoint(cdnDomain) + cdnHost := "https://" + cfg.Bucket + "." + endpoint + ".aliyuncs.com" + + origURL, err := uploadToOSS(cfg, endpoint, origKey, header.Filename, fileData) + if err != nil { + log.Printf("[admin_upload] upload original error: %v", err) + c.JSON(http.StatusBadGateway, model.Error(http.StatusBadGateway, "上传原图到 OSS 失败")) + return + } + + thumbURL := "" + thumbData, thumbErr := imageutil.GenerateThumbnail(fileData, header.Header.Get("Content-Type"), imageutil.DefaultResizeOptions()) + if thumbErr != nil { + log.Printf("[admin_upload] thumbnail generation skipped: %v", thumbErr) + } else { + if url, err := uploadToOSS(cfg, endpoint, thumbKey, "thumb.jpg", thumbData); err != nil { + log.Printf("[admin_upload] upload thumbnail error: %v", err) + } else { + thumbURL = url + } + } + + if origURL == "" { + origURL = cdnHost + "/" + origKey + } + + result := gin.H{"url": origURL} + if thumbURL != "" { + result["thumbnail_url"] = thumbURL + } + c.JSON(http.StatusOK, model.Success(result)) +} + +func uploadToOSS(cfg config.OSSConfig, endpoint, key, filename string, data []byte) (string, error) { + expireSeconds := cfg.TokenExpireSeconds + if expireSeconds <= 0 { + expireSeconds = 300 + } + policy, signature, err := oss.PostPolicy(cfg.Bucket, endpoint, key, cfg.SecretKey, expireSeconds) + if err != nil { + return "", fmt.Errorf("generate policy: %w", err) + } + + uploadURL := oss.UploadHost(cfg.Bucket, endpoint) + var body bytes.Buffer + writer := multipart.NewWriter(&body) + writer.WriteField("key", key) + writer.WriteField("policy", policy) + writer.WriteField("OSSAccessKeyId", cfg.AccessKey) + writer.WriteField("Signature", signature) + fw, err := writer.CreateFormFile("file", filename) + if err != nil { + return "", fmt.Errorf("create form file: %w", err) + } + fw.Write(data) + writer.Close() + + req, err := http.NewRequest(http.MethodPost, uploadURL, &body) + if err != nil { + return "", fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", writer.FormDataContentType()) + + client := &http.Client{Timeout: 60 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", fmt.Errorf("do request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + respBody, _ := io.ReadAll(resp.Body) + return "", fmt.Errorf("oss status=%d body=%s", resp.StatusCode, string(respBody)) + } + + cdnHost := "https://" + cfg.Bucket + "." + endpoint + ".aliyuncs.com" + return cdnHost + "/" + key, nil +} diff --git a/internal/routes/admin_routes.go b/internal/routes/admin_routes.go index 3eb6a8c..d433b09 100644 --- a/internal/routes/admin_routes.go +++ b/internal/routes/admin_routes.go @@ -108,7 +108,8 @@ func registerAdminRoutes( marketing.DELETE("/templates/:id", templateHandler.AdminDelete) marketing.GET("/stats", downloadHandler.AdminStats) - marketing.POST("/upload/qiniu/token", downloadHandler.AdminQiniuToken) + marketing.POST("/upload/oss/token", downloadHandler.AdminUploadToken) + marketing.POST("/upload", downloadHandler.AdminUploadFile) } } } diff --git a/internal/routes/common_routes.go b/internal/routes/common_routes.go index 1bcbae2..9c9021e 100644 --- a/internal/routes/common_routes.go +++ b/internal/routes/common_routes.go @@ -3,19 +3,16 @@ package routes import ( "github.com/gin-gonic/gin" - qiniuhandler "wx_service/internal/common/qiniu/handler" + uploadhandler "wx_service/internal/common/upload/handler" ) -func registerCommonPublicRoutes(api *gin.RouterGroup, uploadHandler *qiniuhandler.UploadHandler) { - // 七牛上传回调:由七牛服务端调用,不能挂登录鉴权。 - api.POST("/common/upload/qiniu/callback", uploadHandler.QiniuCallback) +func registerCommonPublicRoutes(api *gin.RouterGroup, uploadHandler *uploadhandler.UploadHandler) { + api.POST("/common/upload/oss/callback", uploadHandler.UploadCallback) } -func registerCommonRoutes(protected *gin.RouterGroup, uploadHandler *qiniuhandler.UploadHandler) { - // 公共接口(所有小程序共用) +func registerCommonRoutes(protected *gin.RouterGroup, uploadHandler *uploadhandler.UploadHandler) { common := protected.Group("/common") { - // 七牛直传凭证:前端先拿 token,再直传文件到七牛 upload_url - common.POST("/upload/qiniu/token", uploadHandler.QiniuToken) + common.POST("/upload/oss/token", uploadHandler.GetUploadToken) } } diff --git a/internal/routes/marketing_routes.go b/internal/routes/marketing_routes.go index 590e4ef..fb8ac1b 100644 --- a/internal/routes/marketing_routes.go +++ b/internal/routes/marketing_routes.go @@ -46,6 +46,7 @@ func registerMarketingRoutes( admin.DELETE("/templates/:id", templateHandler.AdminDelete) admin.GET("/stats", downloadHandler.AdminStats) - admin.POST("/upload/qiniu/token", downloadHandler.AdminQiniuToken) + admin.POST("/upload/oss/token", downloadHandler.AdminUploadToken) + admin.POST("/upload", downloadHandler.AdminUploadFile) } } diff --git a/internal/routes/routes.go b/internal/routes/routes.go index f710ee3..77368d5 100644 --- a/internal/routes/routes.go +++ b/internal/routes/routes.go @@ -8,7 +8,7 @@ import ( adminhandler "wx_service/internal/admin" authhandler "wx_service/internal/common/auth/handler" - qiniuhandler "wx_service/internal/common/qiniu/handler" + uploadhandler "wx_service/internal/common/upload/handler" rediscache "wx_service/internal/common/redis/cache" oahandler "wx_service/internal/common/wechat_official/handler" expiryhandler "wx_service/internal/expiry" @@ -29,7 +29,7 @@ func Register( smokeHandler *smokehandler.SmokeHandler, quitPlanHandler *smokehandler.QuitPlanHandler, redeemCodeHandler *membershiphandler.RedeemCodeHandler, - uploadHandler *qiniuhandler.UploadHandler, + uploadHandler *uploadhandler.UploadHandler, oaOAuthHandler *oahandler.OAuthHandler, sessionCache *rediscache.SessionUserCache, lawyerHandler *lawyerhandler.LawyerHandler, @@ -48,6 +48,8 @@ func Register( { // 登录接口:用微信 code 换取/创建用户并返回 session_key(作为后续 Bearer Token) api.POST("/auth/login", authHandler.LoginWithWeChat) + // H5 开发调试登录(仅 debug 模式可用) + api.POST("/auth/dev-login", authHandler.DevLogin) // 公众号网页授权:不需要登录(code 本身来自微信授权回调) registerWeChatOfficialRoutes(api, oaOAuthHandler)