file
This commit is contained in:
@@ -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。
|
||||||
@@ -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/*`)同见该文档(含配置、存储与错误码)。
|
||||||
|
|||||||
@@ -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],空表示不限制
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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" }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user