diff --git a/.codebuddy/docs/file-api.md b/.codebuddy/docs/file-api.md new file mode 100644 index 0000000..5d337fe --- /dev/null +++ b/.codebuddy/docs/file-api.md @@ -0,0 +1,149 @@ +# 文件上传服务接口文档 + +> 本文件描述文件上传服务的全部接口,包含**后台管理(`/api/admin/files`)**与**小程序端(`/api/ma/files`)**两套入口。两套入口复用同一套业务逻辑(`internal/service/file`)与存储后端,仅鉴权方式不同。公共约定(统一响应、错误码、中间件)见 [admin-api.md](./admin-api.md) 与 [miniapp-api.md](./miniapp-api.md)。 + +## 1. 概述 + +- **后台入口基础路径**:`/api/admin/files` +- **小程序入口基础路径**:`/api/ma/files` +- **鉴权**: + - 后台接口需携带 `Authorization: Bearer `(由 `AdminAuth` 校验);当前仅要求已登录管理员,**未做细粒度权限码守卫**。 + - 小程序接口需携带 `X-App-ID`(`MiniAppAuth` 校验小程序被纳管且启用)与 `Authorization: Bearer ` / `X-Session-Token`(由 `MiniUserAuth` 校验登录态)。 +- **统一响应**:与全局一致,成功 `code=0`;失败通过 `code` 区分(见 §5)。 +- **上传者身份**:由路由分组自动解析——后台为管理员 `admin_id`,小程序为当前登录用户 `user_id`,并写入文件记录(`uploader_type` / `uploader_id`)。未认证调用上传返回 `40100`。 + +## 2. 配置与存储 + +- 配置位于 `configs/config.yaml` 的 `upload` 段:`storage_type`(当前 `local`)、`local_path`、`base_url`、`max_size`(字节)、`allowed_ext`(扩展名白名单,空表示不限制)。 +- 存储后端抽象为 `internal/pkg/storage.Storage` 接口,当前提供本地磁盘实现(`local`);`minio` 等对象存储**预留**,后续仅需扩展 `storage.New` 的 `case` 分支与对应实现,业务层无感。 +- 本地模式下,文件按日期分片落盘:`{local_path}/{yyyy}/{mm}/{dd}/.`;返回的 `url` 形如 `{base_url}/{yyyy}/{mm}/{dd}/.`,由 `r.Static` 对外提供访问(与 `GET .../raw` 等价)。 +- 建表脚本见 `scripts/003_files.sql`(执行顺序在 RBAC、小程序脚本之后)。 + +## 3. 接口列表 + +### 3.1 `POST /api/admin/files/upload`(后台上传) + +以 `multipart/form-data` 上传单个文件,表单字段名为 `file`。 + +**请求头**:`Authorization: Bearer `;`Content-Type: multipart/form-data`。 + +**表单字段** + +| 字段 | 类型 | 必填 | 说明 | +| --- | --- | --- | --- | +| `file` | file | 是 | 待上传文件。大小受 `max_size` 限制,扩展名受 `allowed_ext` 限制。 | + +**响应** + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "id": 1, + "url": "/files/static/2026/10/27/3f2a...c1.png", + "filename": "3f2a...c1.png", + "origin_name": "我的图片.png", + "mime_type": "image/png", + "size": 12345 + } +} +``` + +**错误码**:缺文件 `40000`;未认证 `40100`;超限 `41300`;类型不允许 `41500`;落盘/落库失败 `50000`。 + +**curl 示例** + +```bash +curl -X POST http://localhost:8080/api/admin/files/upload \ + -H "Authorization: Bearer " \ + -F "file=@/path/to/我的图片.png" +``` + +### 3.2 `GET /api/admin/files/:id`(后台查询元数据) + +查询指定文件的元数据记录。 + +**路径参数**:`id`(uint64)。**响应**:返回 `File` 对象(见 §4);不存在返回 `40400`。 + +### 3.3 `GET /api/admin/files/:id/raw`(后台获取文件流) + +以流形式返回文件内容,按 `mime_type` 设置 `Content-Type`,`Content-Disposition: inline`,可用于浏览器预览/下载。 + +**路径参数**:`id`(uint64)。**响应**:二进制流;不存在返回 `40400`。 + +> 也可直接通过上传返回的 `url` 访问(本地模式由静态目录服务,等效于本接口)。 + +### 3.4 `DELETE /api/admin/files/:id`(后台删除) + +删除文件:同时清理存储内容与 `files` 表元数据记录。 + +**路径参数**:`id`(uint64)。**响应**:`{ "code": 0, "msg": "ok", "data": { "id": 1 } }`;不存在返回 `40400`。 + +### 3.5 `POST /api/ma/files/upload`(小程序上传) + +与 3.1 一致,但鉴权为小程序登录态。 + +**请求头**:`X-App-ID: ` + `Authorization: Bearer `(或 `X-Session-Token`)。 + +**curl 示例** + +```bash +curl -X POST http://localhost:8080/api/ma/files/upload \ + -H "X-App-ID: wx1234567890" \ + -H "Authorization: Bearer a1b2c3..." \ + -F "file=@/path/to/photo.jpg" +``` + +### 3.6 `GET /api/ma/files/:id`(小程序查询元数据) + +同 3.2,返回 `File` 对象;不存在 `40400`。 + +### 3.7 `GET /api/ma/files/:id/raw`(小程序获取文件流) + +同 3.3,返回文件流;不存在 `40400`。 + +### 3.8 `DELETE /api/ma/files/:id`(小程序删除) + +同 3.4,删除文件内容与元数据;不存在 `40400`。 + +## 4. 数据模型 + +### 4.1 File(`files` 表) + +| 字段 | 类型 | JSON | 说明 | +| --- | --- | --- | --- | +| `ID` | uint64 | `id` | 主键,自增。 | +| `StorageKey` | string | `storage_key` | 存储后端内的 key,形如 `2026/10/27/.`。 | +| `Filename` | string | `filename` | 落盘文件名(含扩展名)。 | +| `OriginName` | string | `origin_name` | 用户上传时的原始文件名。 | +| `MimeType` | string | `mime_type` | 由扩展名推断,未知回退 `application/octet-stream`。 | +| `Ext` | string | `ext` | 小写扩展名,不含点。 | +| `Size` | int64 | `size` | 文件字节数。 | +| `UploaderType` | string | `uploader_type` | 上传者类型:`admin` 或 `ma`。 | +| `UploaderID` | uint64 | `uploader_id` | 上传者主键(管理员 ID 或小程序用户 ID)。 | +| `URL` | string | `url` | 可访问地址(`base_url` 拼接 `storage_key`)。 | +| `CreatedAt` | time | `created_at` | 创建时间(`Y-m-d H:i:s`)。 | + +> `GET .../raw` 直接返回二进制流,不返回 `File` JSON;`GET .../:id` 返回上述 `File` 对象。 +> 上传成功响应(§3.1)仅返回摘要字段:`id` / `url` / `filename` / `origin_name` / `mime_type` / `size`。 + +## 5. 错误码 + +| 业务码 | 常量 | 含义 | +| --- | --- | --- | +| `0` | — | 成功 | +| `40000` | `ErrBadRequest` | 请求错误(如未选择文件 / ID 非法) | +| `40100` | `ErrUnauthorized` | 未认证(上传接口要求已登录身份) | +| `40300` | `ErrForbidden` | 禁止访问 | +| `40400` | `ErrNotFound` | 文件不存在 | +| `41300` | `ErrFileTooLarge` | 文件大小超过 `max_size` 限制 | +| `41500` | `ErrFileTypeForbid` | 扩展名不在 `allowed_ext` 白名单 | +| `50000` | `ErrServer` | 服务内部错误(存储 IO / 落库失败等) | + +## 6. 备注 + +- **批量上传**:当前仅支持单文件(`form` 字段 `file`)。批量能力可后续在 handler 层遍历 `c.MultipartForm.File["file"]` 调用同一 `svc.Upload`,Service 无需改动。 +- **对象存储扩展**:接入 minIO/S3 时新增 `internal/pkg/storage/` 实现 `Storage` 接口,在 `storage.New` 增加对应 `case` 并在 `config.yaml` 设置 `upload.storage_type` 即可,接口与 Service 层保持不变。 +- **安全**:存储 key 经 `filepath.Clean` 与根目录二次校验,杜绝目录穿越;文件名以随机值重命名,原始名仅作展示,避免路径注入。 +- **静态访问公开性**:本地模式返回的 `url` 由 `r.Static` 公开服务。若文件需鉴权访问,应改走 `.../raw` 接口(受对应入口鉴权保护),或后续切换为对象存储签名 URL。 diff --git a/.codebuddy/docs/miniapp-api.md b/.codebuddy/docs/miniapp-api.md index a31c35e..0c32e5a 100644 --- a/.codebuddy/docs/miniapp-api.md +++ b/.codebuddy/docs/miniapp-api.md @@ -342,3 +342,4 @@ curl "http://localhost:8080/api/ma/users?page=1&size=20" \ - **多小程序**:新增 `app2`..`app10` 时,按 `internal/handler/miniapp/app1`、`internal/service/miniapp/app1` 的目录结构各自建包,在 `router.New` 中注册即可,互不耦合。新增小程序后须在本文档补充其接口。 - **会话存储**:默认 `MemoryStore`(单实例),多实例部署应替换为 Redis 实现(复用 `session.Store` 接口)。 - **业务接口**:`/api/ma` 已实现 `login`/`logout`、用户资料查询与修改(`/profile`)、按 ID 查询用户(`/users/:id`)与用户分页列表(`/users`)。新增业务接口时,应在路由中增加基于会话 token 的用户身份中间件(`MiniUserAuth`)后再登记,并同步更新本文档 §3。 +- **文件上传**:`/api/ma/files/*`(上传 `POST /upload`、元数据 `GET /:id`、文件流 `GET /:id/raw`、删除 `DELETE /:id`)的小程序端接口见 [file-api.md](./file-api.md);后台端(`/api/admin/files/*`)同见该文档(含配置、存储与错误码)。 diff --git a/configs/config.yaml b/configs/config.yaml index 8582a2f..f9e0d56 100644 --- a/configs/config.yaml +++ b/configs/config.yaml @@ -35,3 +35,13 @@ admin: # 后台 JWT 签名密钥,务必通过环境变量 ADMIN_JWT_SECRET 注入 jwt_secret: "${ADMIN_JWT_SECRET}" token_ttl: 720h + +# 文件上传服务配置。 +# storage_type: local 落盘到 local_path;minio 等对象存储预留,后续接入。 +# allowed_ext 为空表示不限制文件类型(仅受 max_size 约束);如需限制填扩展名列表。 +upload: + storage_type: local + local_path: ./uploads + base_url: /files/static + max_size: 10485760 # 单文件上限 10MB(字节) + allowed_ext: [] # 例如 [jpg, png, pdf],空表示不限制 diff --git a/internal/handler/file/file_handler.go b/internal/handler/file/file_handler.go new file mode 100644 index 0000000..2e59483 --- /dev/null +++ b/internal/handler/file/file_handler.go @@ -0,0 +1,146 @@ +// Package filehdl 是文件上传服务的 HTTP 接入层,分别提供后台 admin 与小程序 ma 两套入口。 +package filehdl + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" + + "portal-mini-go/internal/middleware" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/response" + filesvc "portal-mini-go/internal/service/file" +) + +// Handler 文件上传请求处理。 +type Handler struct { + svc *filesvc.Service +} + +func NewHandler(svc *filesvc.Service) *Handler { return &Handler{svc: svc} } + +// 表单字段名。 +const formFieldFile = "file" + +// Upload 接收 multipart 单文件上传。上传者身份由路由分组决定(admin 或 ma)。 +func (h *Handler) Upload(c *gin.Context) { + fh, err := c.FormFile(formFieldFile) + if err != nil { + c.Error(errors.ErrBadRequest) + return + } + f, err := fh.Open() + if err != nil { + c.Error(errors.Wrap(50000, "open uploaded file", err)) + return + } + defer f.Close() + + typ, id := resolveUploader(c) + if id == 0 { + c.Error(errors.ErrUnauthorized) + return + } + + res, err := h.svc.Upload(c.Request.Context(), filesvc.UploadInput{ + Reader: f, + Size: fh.Size, + Filename: fh.Filename, + UploaderType: typ, + UploaderID: id, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, res) +} + +// Info 返回文件元数据。 +func (h *Handler) Info(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + f, err := h.svc.Get(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + response.OK(c, f) +} + +// Raw 以流形式返回文件内容,按 MIME 设置 Content-Type,供预览/下载。 +func (h *Handler) Raw(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + rc, f, err := h.svc.Download(c.Request.Context(), id) + if err != nil { + c.Error(err) + return + } + defer rc.Close() + extra := map[string]string{ + "Content-Disposition": `inline; filename="` + f.Filename + `"`, + } + c.DataFromReader(http.StatusOK, f.Size, f.MimeType, rc, extra) +} + +// Delete 删除文件(存储内容 + 元数据)。 +func (h *Handler) Delete(c *gin.Context) { + id, ok := parseID(c) + if !ok { + return + } + if err := h.svc.Delete(c.Request.Context(), id); err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"id": id}) +} + +// resolveUploader 从 context 解析上传者身份:后台走 admin_id,小程序走 user_id。 +// 两者均不存在(未认证)返回 ("", 0),由调用方拒绝。 +func resolveUploader(c *gin.Context) (string, uint64) { + if aid, ok := middleware.AdminIDUint(c); ok && aid != 0 { + return "admin", aid + } + if uid := middleware.UserID(c); uid != 0 { + return "ma", uid + } + return "", 0 +} + +func parseID(c *gin.Context) (uint64, bool) { + id, err := strconv.ParseUint(c.Param("id"), 10, 64) + if err != nil { + c.Error(errors.ErrBadRequest) + return 0, false + } + return id, true +} + +// RegisterAdminUpload 在已鉴权(AdminAuth + PermissionLoader)的后台分组下挂载上传接口。 +func RegisterAdminUpload(g *gin.RouterGroup, h *Handler) { + rg := g.Group("/files") + { + rg.POST("/upload", h.Upload) + rg.GET("/:id", h.Info) + rg.GET("/:id/raw", h.Raw) + rg.DELETE("/:id", h.Delete) + } +} + +// RegisterMiniAppUpload 在已鉴权(MiniAppAuth + MiniUserAuth)的小程序分组下挂载上传接口。 +func RegisterMiniAppUpload(g *gin.RouterGroup, h *Handler) { + rg := g.Group("/files") + { + rg.POST("/upload", h.Upload) + rg.GET("/:id", h.Info) + rg.GET("/:id/raw", h.Raw) + rg.DELETE("/:id", h.Delete) + } +} diff --git a/internal/model/file.go b/internal/model/file.go new file mode 100644 index 0000000..fe72de5 --- /dev/null +++ b/internal/model/file.go @@ -0,0 +1,20 @@ +package model + +import "portal-mini-go/internal/pkg/types" + +// File 记录一次上传文件的元数据;实际内容由 StorageKey 指向具体存储实现(本地/对象存储)。 +type File struct { + ID uint64 `gorm:"primaryKey;autoIncrement" json:"id"` + StorageKey string `gorm:"type:varchar(255);index" json:"storage_key"` // 如 2006/01/02/.ext + Filename string `gorm:"type:varchar(255)" json:"filename"` // 落盘文件名(含扩展名) + OriginName string `gorm:"type:varchar(255)" json:"origin_name"` // 用户上传时的原始文件名 + MimeType string `gorm:"type:varchar(128)" json:"mime_type"` + Ext string `gorm:"type:varchar(32)" json:"ext"` // 小写扩展名,不含点 + Size int64 `gorm:"type:bigint" json:"size"` + UploaderType string `gorm:"type:varchar(16)" json:"uploader_type"` // admin | ma + UploaderID uint64 `gorm:"type:bigint;index" json:"uploader_id"` + URL string `gorm:"type:varchar(512)" json:"url"` + CreatedAt types.Time `json:"created_at"` +} + +func (File) TableName() string { return "files" } diff --git a/internal/pkg/config/config.go b/internal/pkg/config/config.go index 5670b8a..26af58c 100644 --- a/internal/pkg/config/config.go +++ b/internal/pkg/config/config.go @@ -16,6 +16,16 @@ type Config struct { MySQL MySQLConfig `mapstructure:"mysql"` Logging LoggingConfig `mapstructure:"logging"` Admin AdminConfig `mapstructure:"admin"` + Upload UploadConfig `mapstructure:"upload"` +} + +// UploadConfig 文件上传相关配置。 +type UploadConfig struct { + StorageType string `mapstructure:"storage_type"` // local | minio(minio 预留) + LocalPath string `mapstructure:"local_path"` // 本地存储根目录(storage_type=local) + BaseURL string `mapstructure:"base_url"` // 文件可访问的基础路径/域名 + MaxSize int64 `mapstructure:"max_size"` // 单文件大小上限(字节),0 表示不限制 + AllowedExt []string `mapstructure:"allowed_ext"` // 扩展名白名单;为空表示不限制类型 } type ServerConfig struct { @@ -97,5 +107,23 @@ func Load(configPath string) (*Config, error) { if err := v.Unmarshal(&cfg); err != nil { return nil, fmt.Errorf("unmarshal config: %w", err) } + applyUploadDefaults(&cfg.Upload) return &cfg, nil } + +// applyUploadDefaults 为上传配置补齐合理默认值。 +func applyUploadDefaults(u *UploadConfig) { + if u.StorageType == "" { + u.StorageType = "local" + } + if u.LocalPath == "" { + u.LocalPath = "./uploads" + } + if u.BaseURL == "" { + u.BaseURL = "/files/static" + } + // MaxSize=0 视为不限制;仅当完全未配置(<0)时兜底为 10MB,避免误配导致无限大。 + if u.MaxSize < 0 { + u.MaxSize = 10 << 20 + } +} diff --git a/internal/pkg/storage/local/local.go b/internal/pkg/storage/local/local.go new file mode 100644 index 0000000..6a026c0 --- /dev/null +++ b/internal/pkg/storage/local/local.go @@ -0,0 +1,90 @@ +// Package local 提供基于本地文件系统的 Storage 实现。 +package local + +import ( + "context" + "io" + "os" + "path/filepath" + "strings" + + "portal-mini-go/internal/pkg/errors" +) + +// Local 是基于本地磁盘的存储实现。 +type Local struct { + root string + baseURL string +} + +// New 构造本地存储,root 为落盘根目录,baseURL 为对外访问基础路径(不含末尾斜杠)。 +func New(root, baseURL string) *Local { + return &Local{root: root, baseURL: strings.TrimRight(baseURL, "/")} +} + +// Put 将内容写入 root/key,自动创建父目录。 +func (l *Local) Put(_ context.Context, key string, r io.Reader, _ int64) error { + p, err := l.safePath(key) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(p), 0o755); err != nil { + return errors.Wrap(50000, "mkdir upload dir", err) + } + f, err := os.Create(p) + if err != nil { + return errors.Wrap(50000, "create upload file", err) + } + defer f.Close() + if _, err := io.Copy(f, r); err != nil { + return errors.Wrap(50000, "write upload file", err) + } + return nil +} + +// Get 打开文件并返回读取流。 +func (l *Local) Get(_ context.Context, key string) (io.ReadCloser, error) { + p, err := l.safePath(key) + if err != nil { + return nil, err + } + f, err := os.Open(p) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "open upload file", err) + } + return f, nil +} + +// Delete 删除文件;不存在视为成功。 +func (l *Local) Delete(_ context.Context, key string) error { + p, err := l.safePath(key) + if err != nil { + return err + } + if err := os.Remove(p); err != nil && !os.IsNotExist(err) { + return errors.Wrap(50000, "delete upload file", err) + } + return nil +} + +// URL 返回 /baseURL/key 形式的访问地址。 +func (l *Local) URL(key string) string { + return l.baseURL + "/" + strings.TrimLeft(key, "/") +} + +// safePath 将 key 解析为 root 内的绝对路径,并禁止任何目录穿越。 +func (l *Local) safePath(key string) (string, error) { + clean := filepath.Clean("/" + key) // 以 / 开头,消除 . 与 .. + if strings.Contains(clean, "..") { + return "", errors.New(40000, "非法的文件 key") + } + p := filepath.Join(l.root, clean) + // 二次校验,确保最终路径没有逃出 root。 + if rel, err := filepath.Rel(l.root, p); err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + return "", errors.New(40000, "非法的文件 key") + } + return p, nil +} diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go new file mode 100644 index 0000000..c10602e --- /dev/null +++ b/internal/pkg/storage/storage.go @@ -0,0 +1,36 @@ +// Package storage 定义文件存储抽象,屏蔽本地磁盘与对象存储(minIO/OSS 等)的差异。 +// 业务层(service)仅依赖 Storage 接口,便于替换存储后端与单测 mock。 +package storage + +import ( + "context" + "fmt" + "io" + + "portal-mini-go/internal/pkg/config" + "portal-mini-go/internal/pkg/storage/local" +) + +// Storage 是文件存储抽象。 +// key 的约定形如 "2006/01/02/.ext",由 service 层生成,按日期分片避免单目录文件过多。 +type Storage interface { + // Put 写入文件内容;size 为文件字节数(<=0 表示未知,由实现自行处理)。 + Put(ctx context.Context, key string, r io.Reader, size int64) error + // Get 返回文件读取流,调用方负责关闭;不存在返回 ErrNotFound。 + Get(ctx context.Context, key string) (io.ReadCloser, error) + // Delete 删除文件;不存在不报错。 + Delete(ctx context.Context, key string) error + // URL 返回客户端可访问的地址(本地为 baseURL 拼接;对象存储可为签名/公开 URL)。 + URL(key string) string +} + +// New 依据配置构造存储实现。 +// 当前支持 local;minio 等对象存储预留(后续在此补充 case 并接入 SDK)。 +func New(cfg config.UploadConfig) (Storage, error) { + switch cfg.StorageType { + case "", "local": + return local.New(cfg.LocalPath, cfg.BaseURL), nil + default: + return nil, fmt.Errorf("storage: 不支持的存储类型 %q", cfg.StorageType) + } +} diff --git a/internal/repository/file/repository.go b/internal/repository/file/repository.go new file mode 100644 index 0000000..b34beb3 --- /dev/null +++ b/internal/repository/file/repository.go @@ -0,0 +1,51 @@ +// Package file 提供文件元数据(files 表)的数据访问。 +// 所有查询均参数化,杜绝 SQL 注入;仅负责持久化,不涉及存储后端 IO。 +package file + +import ( + "context" + + "gorm.io/gorm" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/errors" +) + +// Repository 文件元数据仓储。 +type Repository struct { + db *gorm.DB +} + +func New(db *gorm.DB) *Repository { return &Repository{db: db} } + +// Create 写入文件记录。 +func (r *Repository) Create(ctx context.Context, f *model.File) error { + if err := r.db.WithContext(ctx).Create(f).Error; err != nil { + return errors.Wrap(50000, "create file record", err) + } + return nil +} + +// GetByID 按 ID 查询文件元数据,不存在返回 ErrNotFound。 +func (r *Repository) GetByID(ctx context.Context, id uint64) (*model.File, error) { + var f model.File + if err := r.db.WithContext(ctx).Where("id = ?", id).First(&f).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return nil, errors.ErrNotFound + } + return nil, errors.Wrap(50000, "query file", err) + } + return &f, nil +} + +// Delete 按 ID 删除文件元数据,不存在返回 ErrNotFound。 +func (r *Repository) Delete(ctx context.Context, id uint64) error { + res := r.db.WithContext(ctx).Where("id = ?", id).Delete(&model.File{}) + if res.Error != nil { + return errors.Wrap(50000, "delete file", res.Error) + } + if res.RowsAffected == 0 { + return errors.ErrNotFound + } + return nil +} diff --git a/internal/router/router.go b/internal/router/router.go index 2bbf829..5155e0c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -16,19 +16,23 @@ import ( rbachdl "portal-mini-go/internal/handler/admin/rbac" userhdl "portal-mini-go/internal/handler/admin/user" app1hdl "portal-mini-go/internal/handler/miniapp/app1" + filehdl "portal-mini-go/internal/handler/file" "portal-mini-go/internal/middleware" "portal-mini-go/internal/pkg/config" "portal-mini-go/internal/pkg/response" "portal-mini-go/internal/pkg/session" + "portal-mini-go/internal/pkg/storage" "portal-mini-go/internal/pkg/wechat" adminrepo "portal-mini-go/internal/repository/admin" miniapprepo "portal-mini-go/internal/repository/miniapp" "portal-mini-go/internal/repository/user" + filerepo "portal-mini-go/internal/repository/file" authsvc "portal-mini-go/internal/service/admin/auth" miniappsvc "portal-mini-go/internal/service/admin/miniapp" adminrbac "portal-mini-go/internal/service/admin/rbac" usersvc "portal-mini-go/internal/service/admin/user" app1svc "portal-mini-go/internal/service/miniapp/app1" + filesvc "portal-mini-go/internal/service/file" ) // New 构造并装配整个 HTTP 服务。 @@ -44,6 +48,19 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine { response.OK(c, gin.H{"status": "ok"}) }) + // 文件存储:依据配置构造(当前支持本地磁盘,minIO 等预留)。 + fileStore, err := storage.New(cfg.Upload) + if err != nil { + slog.Error("init storage", slog.Any("err", err)) + os.Exit(1) + } + // 本地存储静态目录对外可访问(与 file.URL 拼接的 baseURL 对齐)。 + r.Static(cfg.Upload.BaseURL, cfg.Upload.LocalPath) + // 共享的文件服务实例,admin 与小程序端复用。 + fileRepo := filerepo.New(db) + fileSvc := filesvc.New(fileRepo, fileStore, cfg.Upload) + fileHdl := filehdl.NewHandler(fileSvc) + // 共享基础设施:微信客户端、用户仓储、小程序仓储。 wx := wechat.NewClient() userRepo := user.New(db) @@ -55,6 +72,11 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine { { app1Svc := app1svc.New(userRepo, miniRepo, wx, store) app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store)) + + // 文件上传服务:小程序端已登录用户可上传/管理文件。 + maAuth := ma.Group("") + maAuth.Use(middleware.MiniUserAuth(store)) + filehdl.RegisterMiniAppUpload(maAuth, fileHdl) } // 后台 RBAC 基础设施:仓储、缓存、权限 Provider、种子初始化。 @@ -100,6 +122,9 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine { // 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。 userSvc := usersvc.New(userRepo) userhdl.Register(authAd, userhdl.NewHandler(userSvc)) + + // 文件上传服务:后台已认证管理员可上传/管理文件(admin 与 ma 双入口复用同一 Service)。 + filehdl.RegisterAdminUpload(authAd, fileHdl) } } diff --git a/internal/service/file/service.go b/internal/service/file/service.go new file mode 100644 index 0000000..fd83227 --- /dev/null +++ b/internal/service/file/service.go @@ -0,0 +1,176 @@ +// Package filesvc 是文件上传/下载/删除的业务逻辑层。 +// 负责大小/类型校验、生成存储 key、调用存储后端并持久化元数据。 +package filesvc + +import ( + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "io" + "mime" + "path" + "strings" + "time" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/config" + "portal-mini-go/internal/pkg/errors" + "portal-mini-go/internal/pkg/storage" + "portal-mini-go/internal/pkg/types" +) + +// 文件相关业务错误(业务码沿用现有 40xxx/41xxx/50xxx 段)。 +var ( + ErrFileTooLarge = errors.New(41300, "文件大小超过限制") + ErrFileTypeForbid = errors.New(41500, "不支持的文件类型") +) + +// FileRepository 是文件元数据仓储接口,便于在单测中以 mock 替换真实 DB。 +type FileRepository interface { + Create(ctx context.Context, f *model.File) error + GetByID(ctx context.Context, id uint64) (*model.File, error) + Delete(ctx context.Context, id uint64) error +} + +// Service 文件上传业务服务。 +type Service struct { + repo FileRepository + store storage.Storage + cfg config.UploadConfig +} + +func New(repo FileRepository, store storage.Storage, cfg config.UploadConfig) *Service { + return &Service{repo: repo, store: store, cfg: cfg} +} + +// UploadInput 上传入参。 +type UploadInput struct { + Reader io.Reader + Size int64 // 字节;<=0 视为非法 + Filename string // 原始文件名,用于取扩展名与展示 + UploaderType string // admin | ma + UploaderID uint64 +} + +// UploadResult 上传成功后的返回摘要。 +type UploadResult struct { + ID uint64 `json:"id"` + URL string `json:"url"` + Filename string `json:"filename"` + OriginName string `json:"origin_name"` + MimeType string `json:"mime_type"` + Size int64 `json:"size"` +} + +// Upload 校验后落盘并返回元数据。 +func (s *Service) Upload(ctx context.Context, in UploadInput) (*UploadResult, error) { + if in.Size <= 0 { + return nil, errors.ErrBadRequest + } + if s.cfg.MaxSize > 0 && in.Size > s.cfg.MaxSize { + return nil, ErrFileTooLarge + } + ext := strings.ToLower(strings.TrimPrefix(path.Ext(in.Filename), ".")) + if !s.allowedExt(ext) { + return nil, ErrFileTypeForbid + } + + key := s.genKey(ext) + if err := s.store.Put(ctx, key, in.Reader, in.Size); err != nil { + return nil, errors.Wrap(50000, "save file to storage", err) + } + + rec := &model.File{ + StorageKey: key, + Filename: path.Base(key), + OriginName: in.Filename, + MimeType: detectMime(ext), + Ext: ext, + Size: in.Size, + UploaderType: in.UploaderType, + UploaderID: in.UploaderID, + URL: s.store.URL(key), + CreatedAt: types.Time{Time: time.Now()}, + } + if err := s.repo.Create(ctx, rec); err != nil { + // 回滚:存储已写入但元数据落库失败,删除孤儿文件。 + _ = s.store.Delete(ctx, key) + return nil, err + } + + return &UploadResult{ + ID: rec.ID, + URL: rec.URL, + Filename: rec.Filename, + OriginName: rec.OriginName, + MimeType: rec.MimeType, + Size: rec.Size, + }, nil +} + +// Get 查询文件元数据。 +func (s *Service) Get(ctx context.Context, id uint64) (*model.File, error) { + return s.repo.GetByID(ctx, id) +} + +// Download 返回文件读取流与元数据;调用方负责关闭流。 +func (s *Service) Download(ctx context.Context, id uint64) (io.ReadCloser, *model.File, error) { + f, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, nil, err + } + rc, err := s.store.Get(ctx, f.StorageKey) + if err != nil { + return nil, nil, errors.Wrap(50000, "read file from storage", err) + } + return rc, f, nil +} + +// Delete 同时删除存储内容与元数据。 +func (s *Service) Delete(ctx context.Context, id uint64) error { + f, err := s.repo.GetByID(ctx, id) + if err != nil { + return err + } + if err := s.store.Delete(ctx, f.StorageKey); err != nil { + return errors.Wrap(50000, "delete file from storage", err) + } + return s.repo.Delete(ctx, id) +} + +// allowedExt 校验扩展名是否在白名单内;白名单为空时放行(由 MaxSize 兜底)。 +func (s *Service) allowedExt(ext string) bool { + if len(s.cfg.AllowedExt) == 0 { + return true + } + for _, e := range s.cfg.AllowedExt { + if strings.EqualFold(ext, strings.TrimPrefix(e, ".")) { + return true + } + } + return false +} + +// genKey 生成 "年/月/日/." 形式的存储 key。 +func (s *Service) genKey(ext string) string { + b := make([]byte, 16) + _, _ = rand.Read(b) + name := hex.EncodeToString(b) + if ext != "" { + name += "." + ext + } + now := time.Now() + return fmt.Sprintf("%04d/%02d/%02d/%s", now.Year(), now.Month(), now.Day(), name) +} + +// detectMime 依据扩展名推断 MIME,未知回退为 application/octet-stream。 +func detectMime(ext string) string { + if ext == "" { + return "application/octet-stream" + } + if t := mime.TypeByExtension("." + ext); t != "" { + return t + } + return "application/octet-stream" +} diff --git a/internal/service/file/service_test.go b/internal/service/file/service_test.go new file mode 100644 index 0000000..d715d6a --- /dev/null +++ b/internal/service/file/service_test.go @@ -0,0 +1,194 @@ +package filesvc_test + +import ( + "bytes" + "context" + "errors" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "portal-mini-go/internal/model" + "portal-mini-go/internal/pkg/config" + "portal-mini-go/internal/pkg/storage" + filesvc "portal-mini-go/internal/service/file" +) + +// --- 测试替身 --- + +type fakeStorage struct { + putErr error + gotKey string + gotSize int64 + data map[string][]byte +} + +func newFakeStorage() *fakeStorage { return &fakeStorage{data: map[string][]byte{}} } + +func (s *fakeStorage) Put(_ context.Context, key string, r io.Reader, size int64) error { + if s.putErr != nil { + return s.putErr + } + b, _ := io.ReadAll(r) + s.data[key] = b + s.gotKey = key + s.gotSize = size + return nil +} +func (s *fakeStorage) Get(_ context.Context, key string) (io.ReadCloser, error) { + b, ok := s.data[key] + if !ok { + return nil, errors.New("not found") + } + return io.NopCloser(bytes.NewReader(b)), nil +} +func (s *fakeStorage) Delete(_ context.Context, key string) error { + delete(s.data, key) + return nil +} +func (s *fakeStorage) URL(key string) string { return "/files/static/" + key } + +type fakeRepo struct { + created *model.File + byID *model.File + getErr error + createErr error + delErr error + deletedID uint64 +} + +func (r *fakeRepo) Create(_ context.Context, f *model.File) error { r.created = f; return r.createErr } +func (r *fakeRepo) GetByID(_ context.Context, id uint64) (*model.File, error) { + if r.getErr != nil { + return nil, r.getErr + } + return r.byID, nil +} +func (r *fakeRepo) Delete(_ context.Context, id uint64) error { r.deletedID = id; return r.delErr } + +// 编译期接口断言。 +var ( + _ filesvc.FileRepository = (*fakeRepo)(nil) + _ storage.Storage = (*fakeStorage)(nil) +) + +func newSvc(cfg config.UploadConfig, store *fakeStorage, repo *fakeRepo) *filesvc.Service { + return filesvc.New(repo, store, cfg) +} + +// --- Upload --- + +func TestUpload_Success(t *testing.T) { + store := newFakeStorage() + repo := &fakeRepo{} + svc := newSvc(config.UploadConfig{MaxSize: 1 << 20}, store, repo) + + res, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader([]byte("hello")), + Size: 5, + Filename: "我的图片.png", + UploaderType: "admin", + UploaderID: 1, + }) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, uint64(1), repo.created.UploaderID) + require.Equal(t, "admin", repo.created.UploaderType) + require.Equal(t, "png", repo.created.Ext) + require.Equal(t, "image/png", repo.created.MimeType) + require.NotEmpty(t, repo.created.StorageKey) + require.Equal(t, store.URL(repo.created.StorageKey), res.URL) + require.Contains(t, store.gotKey, ".png") +} + +func TestUpload_TooLarge(t *testing.T) { + svc := newSvc(config.UploadConfig{MaxSize: 10}, newFakeStorage(), &fakeRepo{}) + _, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader(make([]byte, 11)), + Size: 11, + Filename: "a.png", + }) + require.ErrorIs(t, err, filesvc.ErrFileTooLarge) +} + +func TestUpload_TypeForbidden(t *testing.T) { + svc := newSvc(config.UploadConfig{MaxSize: 1 << 20, AllowedExt: []string{"jpg"}}, newFakeStorage(), &fakeRepo{}) + _, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader([]byte("x")), + Size: 1, + Filename: "a.png", + }) + require.ErrorIs(t, err, filesvc.ErrFileTypeForbid) +} + +func TestUpload_EmptyWhitelistAllowsAll(t *testing.T) { + store := newFakeStorage() + repo := &fakeRepo{} + svc := newSvc(config.UploadConfig{MaxSize: 1 << 20}, store, repo) + _, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader([]byte("x")), + Size: 1, + Filename: "a.unknown-ext", + }) + require.NoError(t, err) + require.Equal(t, "unknown-ext", repo.created.Ext) +} + +func TestUpload_StoreErrorNoCreate(t *testing.T) { + store := newFakeStorage() + store.putErr = errors.New("disk full") + repo := &fakeRepo{} + svc := newSvc(config.UploadConfig{}, store, repo) + _, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader([]byte("x")), Size: 1, Filename: "a.png", + }) + require.Error(t, err) + require.Nil(t, repo.created, "存储失败不应写入元数据") +} + +func TestUpload_RollbackOnRepoError(t *testing.T) { + store := newFakeStorage() + repo := &fakeRepo{createErr: errors.New("db down")} + svc := newSvc(config.UploadConfig{}, store, repo) + _, err := svc.Upload(context.Background(), filesvc.UploadInput{ + Reader: bytes.NewReader([]byte("x")), Size: 1, Filename: "a.png", + }) + require.Error(t, err) + require.Empty(t, store.data, "元数据落库失败应回滚已落盘文件") +} + +// --- Get / Download / Delete --- + +func TestGet(t *testing.T) { + want := &model.File{ID: 7, OriginName: "doc.pdf"} + svc := newSvc(config.UploadConfig{}, newFakeStorage(), &fakeRepo{byID: want}) + got, err := svc.Get(context.Background(), 7) + require.NoError(t, err) + require.Equal(t, want, got) +} + +func TestDownload(t *testing.T) { + store := newFakeStorage() + store.data["2006/01/01/abc.png"] = []byte("binary") + repo := &fakeRepo{byID: &model.File{ID: 3, StorageKey: "2006/01/01/abc.png", MimeType: "image/png", Size: 6}} + svc := newSvc(config.UploadConfig{}, store, repo) + + rc, f, err := svc.Download(context.Background(), 3) + require.NoError(t, err) + defer rc.Close() + b, _ := io.ReadAll(rc) + require.Equal(t, []byte("binary"), b) + require.Equal(t, "image/png", f.MimeType) +} + +func TestDelete(t *testing.T) { + store := newFakeStorage() + store.data["k"] = []byte("x") + repo := &fakeRepo{byID: &model.File{ID: 9, StorageKey: "k"}} + svc := newSvc(config.UploadConfig{}, store, repo) + + require.NoError(t, svc.Delete(context.Background(), 9)) + require.Equal(t, uint64(9), repo.deletedID) + require.Empty(t, store.data) +} diff --git a/scripts/003_files.sql b/scripts/003_files.sql new file mode 100644 index 0000000..ce0983f --- /dev/null +++ b/scripts/003_files.sql @@ -0,0 +1,20 @@ +-- 文件上传服务建表脚本(files)。 +-- 与现有脚本风格保持一致:InnoDB + utf8mb4,时间字段用 DATETIME(3)(对应 model 的 types.Time)。 +-- 实际文件内容由存储后端(本地磁盘 / 对象存储)管理,本表仅持久化元数据。 + +CREATE TABLE IF NOT EXISTS `files` ( + `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, + `storage_key` VARCHAR(255) NOT NULL, + `filename` VARCHAR(255) NOT NULL DEFAULT '', + `origin_name` VARCHAR(255) NOT NULL DEFAULT '', + `mime_type` VARCHAR(128) NOT NULL DEFAULT '', + `ext` VARCHAR(32) NOT NULL DEFAULT '', + `size` BIGINT NOT NULL DEFAULT 0, + `uploader_type` VARCHAR(16) NOT NULL DEFAULT '', + `uploader_id` BIGINT NOT NULL DEFAULT 0, + `url` VARCHAR(512) NOT NULL DEFAULT '', + `created_at` DATETIME(3) NULL, + PRIMARY KEY (`id`), + KEY `idx_files_storage_key` (`storage_key`), + KEY `idx_files_uploader` (`uploader_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;