This commit is contained in:
2026-10-27 22:48:36 +08:00
parent cf832f0962
commit 1231251ea4
13 changed files with 946 additions and 0 deletions
+149
View File
@@ -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 <JWT>`(由 `AdminAuth` 校验);当前仅要求已登录管理员,**未做细粒度权限码守卫**。
- 小程序接口需携带 `X-App-ID`(`MiniAppAuth` 校验小程序被纳管且启用)与 `Authorization: Bearer <token>` / `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}/<rand>.<ext>`;返回的 `url` 形如 `{base_url}/{yyyy}/{mm}/{dd}/<rand>.<ext>`,由 `r.Static` 对外提供访问(与 `GET .../raw` 等价)。
- 建表脚本见 `scripts/003_files.sql`(执行顺序在 RBAC、小程序脚本之后)。
## 3. 接口列表
### 3.1 `POST /api/admin/files/upload`(后台上传)
以 `multipart/form-data` 上传单个文件,表单字段名为 `file`。
**请求头**:`Authorization: Bearer <JWT>`;`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 <JWT>" \
-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: <AppID>` + `Authorization: Bearer <token>`(或 `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/<rand>.<ext>`。 |
| `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/<backend>` 实现 `Storage` 接口,在 `storage.New` 增加对应 `case` 并在 `config.yaml` 设置 `upload.storage_type` 即可,接口与 Service 层保持不变。
- **安全**:存储 key 经 `filepath.Clean` 与根目录二次校验,杜绝目录穿越;文件名以随机值重命名,原始名仅作展示,避免路径注入。
- **静态访问公开性**:本地模式返回的 `url` 由 `r.Static` 公开服务。若文件需鉴权访问,应改走 `.../raw` 接口(受对应入口鉴权保护),或后续切换为对象存储签名 URL。
+1
View File
@@ -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` 中注册即可,互不耦合。新增小程序后须在本文档补充其接口。 - **多小程序**:新增 `app2`..`app10` 时,按 `internal/handler/miniapp/app1`、`internal/service/miniapp/app1` 的目录结构各自建包,在 `router.New` 中注册即可,互不耦合。新增小程序后须在本文档补充其接口。
- **会话存储**:默认 `MemoryStore`(单实例),多实例部署应替换为 Redis 实现(复用 `session.Store` 接口)。 - **会话存储**:默认 `MemoryStore`(单实例),多实例部署应替换为 Redis 实现(复用 `session.Store` 接口)。
- **业务接口**:`/api/ma` 已实现 `login`/`logout`、用户资料查询与修改(`/profile`)、按 ID 查询用户(`/users/:id`)与用户分页列表(`/users`)。新增业务接口时,应在路由中增加基于会话 token 的用户身份中间件(`MiniUserAuth`)后再登记,并同步更新本文档 §3。 - **业务接口**:`/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/*`)同见该文档(含配置、存储与错误码)。
+10
View File
@@ -35,3 +35,13 @@ admin:
# 后台 JWT 签名密钥,务必通过环境变量 ADMIN_JWT_SECRET 注入 # 后台 JWT 签名密钥,务必通过环境变量 ADMIN_JWT_SECRET 注入
jwt_secret: "${ADMIN_JWT_SECRET}" jwt_secret: "${ADMIN_JWT_SECRET}"
token_ttl: 720h 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],空表示不限制
+146
View File
@@ -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)
}
}
+20
View File
@@ -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/<rand>.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" }
+28
View File
@@ -16,6 +16,16 @@ type Config struct {
MySQL MySQLConfig `mapstructure:"mysql"` MySQL MySQLConfig `mapstructure:"mysql"`
Logging LoggingConfig `mapstructure:"logging"` Logging LoggingConfig `mapstructure:"logging"`
Admin AdminConfig `mapstructure:"admin"` 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 { type ServerConfig struct {
@@ -97,5 +107,23 @@ func Load(configPath string) (*Config, error) {
if err := v.Unmarshal(&cfg); err != nil { if err := v.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("unmarshal config: %w", err) return nil, fmt.Errorf("unmarshal config: %w", err)
} }
applyUploadDefaults(&cfg.Upload)
return &cfg, nil 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
}
}
+90
View File
@@ -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
}
+36
View File
@@ -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/<rand>.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)
}
}
+51
View File
@@ -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
}
+25
View File
@@ -16,19 +16,23 @@ import (
rbachdl "portal-mini-go/internal/handler/admin/rbac" rbachdl "portal-mini-go/internal/handler/admin/rbac"
userhdl "portal-mini-go/internal/handler/admin/user" userhdl "portal-mini-go/internal/handler/admin/user"
app1hdl "portal-mini-go/internal/handler/miniapp/app1" app1hdl "portal-mini-go/internal/handler/miniapp/app1"
filehdl "portal-mini-go/internal/handler/file"
"portal-mini-go/internal/middleware" "portal-mini-go/internal/middleware"
"portal-mini-go/internal/pkg/config" "portal-mini-go/internal/pkg/config"
"portal-mini-go/internal/pkg/response" "portal-mini-go/internal/pkg/response"
"portal-mini-go/internal/pkg/session" "portal-mini-go/internal/pkg/session"
"portal-mini-go/internal/pkg/storage"
"portal-mini-go/internal/pkg/wechat" "portal-mini-go/internal/pkg/wechat"
adminrepo "portal-mini-go/internal/repository/admin" adminrepo "portal-mini-go/internal/repository/admin"
miniapprepo "portal-mini-go/internal/repository/miniapp" miniapprepo "portal-mini-go/internal/repository/miniapp"
"portal-mini-go/internal/repository/user" "portal-mini-go/internal/repository/user"
filerepo "portal-mini-go/internal/repository/file"
authsvc "portal-mini-go/internal/service/admin/auth" authsvc "portal-mini-go/internal/service/admin/auth"
miniappsvc "portal-mini-go/internal/service/admin/miniapp" miniappsvc "portal-mini-go/internal/service/admin/miniapp"
adminrbac "portal-mini-go/internal/service/admin/rbac" adminrbac "portal-mini-go/internal/service/admin/rbac"
usersvc "portal-mini-go/internal/service/admin/user" usersvc "portal-mini-go/internal/service/admin/user"
app1svc "portal-mini-go/internal/service/miniapp/app1" app1svc "portal-mini-go/internal/service/miniapp/app1"
filesvc "portal-mini-go/internal/service/file"
) )
// New 构造并装配整个 HTTP 服务。 // 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"}) 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() wx := wechat.NewClient()
userRepo := user.New(db) 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) app1Svc := app1svc.New(userRepo, miniRepo, wx, store)
app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store)) app1hdl.Register(ma, app1hdl.NewHandler(app1Svc), middleware.MiniUserAuth(store))
// 文件上传服务:小程序端已登录用户可上传/管理文件。
maAuth := ma.Group("")
maAuth.Use(middleware.MiniUserAuth(store))
filehdl.RegisterMiniAppUpload(maAuth, fileHdl)
} }
// 后台 RBAC 基础设施:仓储、缓存、权限 Provider、种子初始化。 // 后台 RBAC 基础设施:仓储、缓存、权限 Provider、种子初始化。
@@ -100,6 +122,9 @@ func New(cfg *config.Config, db *gorm.DB, store session.Store) *gin.Engine {
// 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。 // 用户管理:各小程序端用户(列表、详情、编辑、禁用/启用)。
userSvc := usersvc.New(userRepo) userSvc := usersvc.New(userRepo)
userhdl.Register(authAd, userhdl.NewHandler(userSvc)) userhdl.Register(authAd, userhdl.NewHandler(userSvc))
// 文件上传服务:后台已认证管理员可上传/管理文件(admin 与 ma 双入口复用同一 Service)。
filehdl.RegisterAdminUpload(authAd, fileHdl)
} }
} }
+176
View File
@@ -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 生成 "年/月/日/<rand>.<ext>" 形式的存储 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"
}
+194
View File
@@ -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)
}
+20
View File
@@ -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;