From 1f6cd63815bd0bc454fcf9a4d035199233f33359 Mon Sep 17 00:00:00 2001 From: wangrui233 Date: Tue, 27 Oct 2026 22:55:36 +0800 Subject: [PATCH] update --- .codebuddy/docs/file-api.md | 80 +++++++++++++++++++++----- internal/handler/file/file_handler.go | 27 +++++++++ internal/repository/file/repository.go | 34 +++++++++++ internal/service/file/service.go | 29 ++++++++++ internal/service/file/service_test.go | 56 ++++++++++++++---- 5 files changed, 203 insertions(+), 23 deletions(-) diff --git a/.codebuddy/docs/file-api.md b/.codebuddy/docs/file-api.md index 5d337fe..d67d497 100644 --- a/.codebuddy/docs/file-api.md +++ b/.codebuddy/docs/file-api.md @@ -21,7 +21,61 @@ ## 3. 接口列表 -### 3.1 `POST /api/admin/files/upload`(后台上传) +### 3.1 `GET /api/admin/files`(后台文件列表) + +分页列出文件,支持按上传者类型/ID 与关键字过滤,供后台「文件管理」栏目使用。 + +**请求头**:`Authorization: Bearer `。 + +**查询参数** + +| 参数 | 类型 | 必填 | 默认值 | 说明 | +| --- | --- | --- | --- | --- | +| `page` | int | 否 | `1` | 页码,从 1 开始。 | +| `size` | int | 否 | `20` | 每页条数。 | +| `keyword` | string | 否 | 空 | 模糊匹配 `origin_name` 或 `filename`。 | +| `uploader_type` | string | 否 | 空 | 按上传者类型过滤:`admin` / `ma`;空表示不限。 | +| `uploader_id` | uint64 | 否 | `0` | 按上传者主键过滤;`0` 表示不限。 | + +**响应** + +```json +{ + "code": 0, + "msg": "ok", + "data": { + "list": [ + { + "id": 1, + "storage_key": "2026/10/27/3f2a...c1.png", + "filename": "3f2a...c1.png", + "origin_name": "我的图片.png", + "mime_type": "image/png", + "ext": "png", + "size": 12345, + "uploader_type": "admin", + "uploader_id": 1, + "url": "/files/static/2026/10/27/3f2a...c1.png", + "created_at": "2026-10-27 22:00:00" + } + ], + "total": 1, + "page": 1, + "size": 20 + } +} +``` + +**错误码**:未认证 `40100`;查询失败 `50000`。 + +**curl 示例** + +```bash +curl "http://localhost:8080/api/admin/files?keyword=png&page=1&size=20" \ + -H "Authorization: Bearer " +``` + +### 3.2 `POST /api/admin/files/upload`(后台上传) 以 `multipart/form-data` 上传单个文件,表单字段名为 `file`。 @@ -60,13 +114,13 @@ curl -X POST http://localhost:8080/api/admin/files/upload \ -F "file=@/path/to/我的图片.png" ``` -### 3.2 `GET /api/admin/files/:id`(后台查询元数据) +### 3.3 `GET /api/admin/files/:id`(后台查询元数据) 查询指定文件的元数据记录。 **路径参数**:`id`(uint64)。**响应**:返回 `File` 对象(见 §4);不存在返回 `40400`。 -### 3.3 `GET /api/admin/files/:id/raw`(后台获取文件流) +### 3.4 `GET /api/admin/files/:id/raw`(后台获取文件流) 以流形式返回文件内容,按 `mime_type` 设置 `Content-Type`,`Content-Disposition: inline`,可用于浏览器预览/下载。 @@ -74,15 +128,15 @@ curl -X POST http://localhost:8080/api/admin/files/upload \ > 也可直接通过上传返回的 `url` 访问(本地模式由静态目录服务,等效于本接口)。 -### 3.4 `DELETE /api/admin/files/:id`(后台删除) +### 3.5 `DELETE /api/admin/files/:id`(后台删除) 删除文件:同时清理存储内容与 `files` 表元数据记录。 **路径参数**:`id`(uint64)。**响应**:`{ "code": 0, "msg": "ok", "data": { "id": 1 } }`;不存在返回 `40400`。 -### 3.5 `POST /api/ma/files/upload`(小程序上传) +### 3.6 `POST /api/ma/files/upload`(小程序上传) -与 3.1 一致,但鉴权为小程序登录态。 +与 3.2 一致,但鉴权为小程序登录态。 **请求头**:`X-App-ID: ` + `Authorization: Bearer `(或 `X-Session-Token`)。 @@ -95,17 +149,17 @@ curl -X POST http://localhost:8080/api/ma/files/upload \ -F "file=@/path/to/photo.jpg" ``` -### 3.6 `GET /api/ma/files/:id`(小程序查询元数据) +### 3.7 `GET /api/ma/files/:id`(小程序查询元数据) -同 3.2,返回 `File` 对象;不存在 `40400`。 +同 3.3,返回 `File` 对象;不存在 `40400`。 -### 3.7 `GET /api/ma/files/:id/raw`(小程序获取文件流) +### 3.8 `GET /api/ma/files/:id/raw`(小程序获取文件流) -同 3.3,返回文件流;不存在 `40400`。 +同 3.4,返回文件流;不存在 `40400`。 -### 3.8 `DELETE /api/ma/files/:id`(小程序删除) +### 3.9 `DELETE /api/ma/files/:id`(小程序删除) -同 3.4,删除文件内容与元数据;不存在 `40400`。 +同 3.5,删除文件内容与元数据;不存在 `40400`。 ## 4. 数据模型 @@ -126,7 +180,7 @@ curl -X POST http://localhost:8080/api/ma/files/upload \ | `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`。 +> 上传成功响应(§3.2)仅返回摘要字段:`id` / `url` / `filename` / `origin_name` / `mime_type` / `size`。 ## 5. 错误码 diff --git a/internal/handler/file/file_handler.go b/internal/handler/file/file_handler.go index 2e59483..14da3ea 100644 --- a/internal/handler/file/file_handler.go +++ b/internal/handler/file/file_handler.go @@ -71,6 +71,32 @@ func (h *Handler) Info(c *gin.Context) { response.OK(c, f) } +// List 分页列表查询文件(后台文件管理栏目使用)。支持 query:keyword(原始/落盘文件名)、 +// uploader_type、uploader_id、page、size。 +func (h *Handler) List(c *gin.Context) { + page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) + size, _ := strconv.Atoi(c.DefaultQuery("size", "20")) + keyword := c.Query("keyword") + typ := c.Query("uploader_type") + var uid uint64 + if v := c.Query("uploader_id"); v != "" { + uid, _ = strconv.ParseUint(v, 10, 64) + } + + list, total, err := h.svc.List(c.Request.Context(), filesvc.ListReq{ + UploaderType: typ, + UploaderID: uid, + Keyword: keyword, + Page: page, + Size: size, + }) + if err != nil { + c.Error(err) + return + } + response.OK(c, gin.H{"list": list, "total": total, "page": page, "size": size}) +} + // Raw 以流形式返回文件内容,按 MIME 设置 Content-Type,供预览/下载。 func (h *Handler) Raw(c *gin.Context) { id, ok := parseID(c) @@ -127,6 +153,7 @@ func parseID(c *gin.Context) (uint64, bool) { func RegisterAdminUpload(g *gin.RouterGroup, h *Handler) { rg := g.Group("/files") { + rg.GET("", h.List) rg.POST("/upload", h.Upload) rg.GET("/:id", h.Info) rg.GET("/:id/raw", h.Raw) diff --git a/internal/repository/file/repository.go b/internal/repository/file/repository.go index b34beb3..33b4d96 100644 --- a/internal/repository/file/repository.go +++ b/internal/repository/file/repository.go @@ -49,3 +49,37 @@ func (r *Repository) Delete(ctx context.Context, id uint64) error { } return nil } + +// ListFilter 文件列表过滤条件。 +type ListFilter struct { + UploaderType string // 上传者类型(admin/ma),为空表示不限 + UploaderID uint64 // 上传者主键,0 表示不限 + Keyword string // 模糊匹配 origin_name 或 filename,为空表示不限 + Page int // 页码,从 1 开始 + Size int // 每页条数 +} + +// List 分页查询文件,所有条件均为可选。返回列表、总数与错误。 +func (r *Repository) List(ctx context.Context, f ListFilter) ([]model.File, int64, error) { + q := r.db.WithContext(ctx).Model(&model.File{}) + if f.UploaderType != "" { + q = q.Where("uploader_type = ?", f.UploaderType) + } + if f.UploaderID != 0 { + q = q.Where("uploader_id = ?", f.UploaderID) + } + if f.Keyword != "" { + kw := "%" + f.Keyword + "%" + q = q.Where("origin_name LIKE ? OR filename LIKE ?", kw, kw) + } + + var total int64 + if err := q.Count(&total).Error; err != nil { + return nil, 0, errors.Wrap(50000, "count files", err) + } + var list []model.File + if err := q.Order("id DESC").Offset((f.Page - 1) * f.Size).Limit(f.Size).Find(&list).Error; err != nil { + return nil, 0, errors.Wrap(50000, "list files", err) + } + return list, total, nil +} diff --git a/internal/service/file/service.go b/internal/service/file/service.go index fd83227..68751f7 100644 --- a/internal/service/file/service.go +++ b/internal/service/file/service.go @@ -18,6 +18,7 @@ import ( "portal-mini-go/internal/pkg/errors" "portal-mini-go/internal/pkg/storage" "portal-mini-go/internal/pkg/types" + "portal-mini-go/internal/repository/file" ) // 文件相关业务错误(业务码沿用现有 40xxx/41xxx/50xxx 段)。 @@ -31,6 +32,7 @@ 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 + List(ctx context.Context, filter file.ListFilter) ([]model.File, int64, error) } // Service 文件上传业务服务。 @@ -114,6 +116,33 @@ func (s *Service) Get(ctx context.Context, id uint64) (*model.File, error) { return s.repo.GetByID(ctx, id) } +// ListReq 文件列表查询条件。 +type ListReq struct { + UploaderType string // 上传者类型(admin/ma),空表示不限 + UploaderID uint64 // 上传者主键,0 表示不限 + Keyword string // 模糊匹配 origin_name 或 filename + Page int // 页码,从 1 开始 + Size int // 每页条数 +} + +// List 分页查询文件,缺省第 1 页、每页 20 条。 +func (s *Service) List(ctx context.Context, req ListReq) ([]model.File, int64, error) { + page, size := req.Page, req.Size + if page <= 0 { + page = 1 + } + if size <= 0 { + size = 20 + } + return s.repo.List(ctx, file.ListFilter{ + UploaderType: req.UploaderType, + UploaderID: req.UploaderID, + Keyword: req.Keyword, + Page: page, + Size: size, + }) +} + // Download 返回文件读取流与元数据;调用方负责关闭流。 func (s *Service) Download(ctx context.Context, id uint64) (io.ReadCloser, *model.File, error) { f, err := s.repo.GetByID(ctx, id) diff --git a/internal/service/file/service_test.go b/internal/service/file/service_test.go index d715d6a..ba7a4f4 100644 --- a/internal/service/file/service_test.go +++ b/internal/service/file/service_test.go @@ -12,16 +12,17 @@ import ( "portal-mini-go/internal/model" "portal-mini-go/internal/pkg/config" "portal-mini-go/internal/pkg/storage" + "portal-mini-go/internal/repository/file" filesvc "portal-mini-go/internal/service/file" ) // --- 测试替身 --- type fakeStorage struct { - putErr error - gotKey string + putErr error + gotKey string gotSize int64 - data map[string][]byte + data map[string][]byte } func newFakeStorage() *fakeStorage { return &fakeStorage{data: map[string][]byte{}} } @@ -50,12 +51,16 @@ func (s *fakeStorage) Delete(_ context.Context, key string) error { 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 + created *model.File + byID *model.File + getErr error + createErr error + delErr error + deletedID uint64 + listed []model.File + listTotal int64 + listErr error + listFilter file.ListFilter } func (r *fakeRepo) Create(_ context.Context, f *model.File) error { r.created = f; return r.createErr } @@ -66,11 +71,18 @@ func (r *fakeRepo) GetByID(_ context.Context, id uint64) (*model.File, error) { return r.byID, nil } func (r *fakeRepo) Delete(_ context.Context, id uint64) error { r.deletedID = id; return r.delErr } +func (r *fakeRepo) List(_ context.Context, f file.ListFilter) ([]model.File, int64, error) { + r.listFilter = f + if r.listErr != nil { + return nil, 0, r.listErr + } + return r.listed, r.listTotal, nil +} // 编译期接口断言。 var ( _ filesvc.FileRepository = (*fakeRepo)(nil) - _ storage.Storage = (*fakeStorage)(nil) + _ storage.Storage = (*fakeStorage)(nil) ) func newSvc(cfg config.UploadConfig, store *fakeStorage, repo *fakeRepo) *filesvc.Service { @@ -192,3 +204,27 @@ func TestDelete(t *testing.T) { require.Equal(t, uint64(9), repo.deletedID) require.Empty(t, store.data) } + +func TestList(t *testing.T) { + want := []model.File{{ID: 1, OriginName: "a.png"}, {ID: 2, OriginName: "b.jpg"}} + repo := &fakeRepo{listed: want, listTotal: 2} + svc := newSvc(config.UploadConfig{}, newFakeStorage(), repo) + + got, total, err := svc.List(context.Background(), filesvc.ListReq{Keyword: "png", Page: 2, Size: 15}) + require.NoError(t, err) + require.Equal(t, int64(2), total) + require.Equal(t, want, got) + require.Equal(t, "png", repo.listFilter.Keyword) + require.Equal(t, 2, repo.listFilter.Page) + require.Equal(t, 15, repo.listFilter.Size) +} + +func TestList_Defaults(t *testing.T) { + repo := &fakeRepo{listed: nil, listTotal: 0} + svc := newSvc(config.UploadConfig{}, newFakeStorage(), repo) + + _, _, err := svc.List(context.Background(), filesvc.ListReq{}) + require.NoError(t, err) + require.Equal(t, 1, repo.listFilter.Page, "缺省页码应为 1") + require.Equal(t, 20, repo.listFilter.Size, "缺省每页应为 20") +}