新增文件上传接口与用户头像裁剪上传

- 新增 storage 配置(存储目录、单文件大小上限),ConfigVersion 3→4 自动补全
- internal/file:上传(sha256 秒传去重)、删除(上传者或管理员,引用中 409)、公开查看;本地存储 + 操作日志 + 引用计数,仅安全类型内联防存储型 XSS
- internal/avatar:PUT/DELETE /api/me/avatar,自动管理头像文件引用与旧头像解绑
- 前端引入 vue-advanced-cropper,个人中心支持上传/更换/删除头像,裁剪输出 512×512 JPEG;http 请求支持 FormData
- 导出 auth.CurrentUser、新增 model.User.IsAdmin 与 testutil 多部件上传辅助,补充接口测试并重新生成 Swagger 文档
This commit is contained in:
2026-09-21 20:50:19 +08:00
parent 866368537c
commit 9bf397064d
27 files changed
+2478 -31

No files matched your search

+3 -1
View File
@@ -14,6 +14,8 @@ internal/
├── usergroup/ 用户组管理
├── note/ 便签
├── site/ 站点信息
├── file/ 文件上传、删除、查看与本地存储
├── avatar/ 当前用户头像
├── httpx/ HTTP 公共能力:ErrorResponse、分页、ID 解析
├── utils/ 通用工具(与业务无关的公共函数)
├── model/ 数据库模型
@@ -84,7 +86,7 @@ password, err := utils.RandomString(16)
- **响应文案一律英文**,统一错误响应 `httpx.ErrorResponse`,如 `invalid request``record not found``internal server error``unauthorized or session expired`
- 分页参数用 `httpx.ParsePagination(c)`;路径 ID 用 `httpx.ParseID(c)`(用户组用 `usergroup` 内部的 `parseGroupID`,允许 id 0
- Swagger 注解:`@Summary` / `@Description` / `@Tags`(按权限取 `public` / `user` / `admin`/ `@Security BearerAuth` / 401、403、500 失败响应;注解修改后执行 `go generate ./...` 重新生成 `docs/`
- 路由按权限挂载:公开(`/health``/swagger``/auth/*``GET /site`、需登录、仅管理员
- 路由按权限挂载:公开(`/health``/swagger``/auth/*``GET /site``GET /files/:id`)、需登录(个人资料、头像、文件上传删除、notes)、仅管理员
## 4. 数据库与迁移
+339
View File
@@ -125,6 +125,187 @@ const docTemplate = `{
}
}
},
"/files": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Upload a file",
"parameters": [
{
"type": "file",
"description": "File content",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/model.File"
}
},
"400": {
"description": "invalid request or empty file",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/files/{id}": {
"get": {
"description": "Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404.",
"produces": [
"application/octet-stream"
],
"tags": [
"public"
],
"summary": "Get file content",
"parameters": [
{
"type": "integer",
"example": 1,
"description": "File ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "invalid id",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"404": {
"description": "record not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count \u003e 0) return 409.",
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Delete a file",
"parameters": [
{
"type": "integer",
"example": 1,
"description": "File ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Deleted"
},
"400": {
"description": "invalid id",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "permission denied or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"404": {
"description": "record not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"409": {
"description": "file is in use",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Check service and database connectivity; returns 503 when the database is unavailable.",
@@ -249,6 +430,114 @@ const docTemplate = `{
}
}
},
"/me/avatar": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Upload an image as the authenticated user's avatar (multipart field file, image only). The avatar URL is stored on the user and the file reference count is managed automatically.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Update current user avatar",
"parameters": [
{
"type": "file",
"description": "Avatar image",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "invalid request, empty file, or not an image",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Clear the authenticated user's avatar and release the file reference when it points to a local file.",
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Delete current user avatar",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/notes": {
"get": {
"security": [
@@ -1422,6 +1711,56 @@ const docTemplate = `{
}
}
},
"model.File": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"extension": {
"type": "string"
},
"hash": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_referenced_at": {
"type": "string"
},
"metadata": {
"type": "string"
},
"mime_type": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"ref_count": {
"type": "integer"
},
"size": {
"type": "integer"
},
"status": {
"type": "integer"
},
"storage": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"uploader_id": {
"type": "integer"
}
}
},
"model.Note": {
"type": "object",
"properties": {
+339
View File
@@ -118,6 +118,187 @@
}
}
},
"/files": {
"post": {
"security": [
{
"BearerAuth": []
}
],
"description": "Upload a file (multipart field file). Content is deduplicated by sha256; the returned file has ref_count 0 until a business reference is acquired. Size limit from storage.max_size_mb.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Upload a file",
"parameters": [
{
"type": "file",
"description": "File content",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"201": {
"description": "Created",
"schema": {
"$ref": "#/definitions/model.File"
}
},
"400": {
"description": "invalid request or empty file",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/files/{id}": {
"get": {
"description": "Public file content. Images, videos, audio, PDF and plain text are served inline; other types are served as attachments. Disabled files return 404.",
"produces": [
"application/octet-stream"
],
"tags": [
"public"
],
"summary": "Get file content",
"parameters": [
{
"type": "integer",
"example": 1,
"description": "File ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "file"
}
},
"400": {
"description": "invalid id",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"404": {
"description": "record not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Delete a file physically and keep the record with status 0; uploader or admin only. Files still referenced (ref_count \u003e 0) return 409.",
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Delete a file",
"parameters": [
{
"type": "integer",
"example": 1,
"description": "File ID",
"name": "id",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "Deleted"
},
"400": {
"description": "invalid id",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "permission denied or account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"404": {
"description": "record not found",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"409": {
"description": "file is in use",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/health": {
"get": {
"description": "Check service and database connectivity; returns 503 when the database is unavailable.",
@@ -242,6 +423,114 @@
}
}
},
"/me/avatar": {
"put": {
"security": [
{
"BearerAuth": []
}
],
"description": "Upload an image as the authenticated user's avatar (multipart field file, image only). The avatar URL is stored on the user and the file reference count is managed automatically.",
"consumes": [
"multipart/form-data"
],
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Update current user avatar",
"parameters": [
{
"type": "file",
"description": "Avatar image",
"name": "file",
"in": "formData",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"400": {
"description": "invalid request, empty file, or not an image",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"413": {
"description": "file too large",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
},
"delete": {
"security": [
{
"BearerAuth": []
}
],
"description": "Clear the authenticated user's avatar and release the file reference when it points to a local file.",
"produces": [
"application/json"
],
"tags": [
"user"
],
"summary": "Delete current user avatar",
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/model.User"
}
},
"401": {
"description": "unauthorized or session expired",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"403": {
"description": "account disabled",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
},
"500": {
"description": "Internal Server Error",
"schema": {
"$ref": "#/definitions/httpx.ErrorResponse"
}
}
}
}
},
"/notes": {
"get": {
"security": [
@@ -1415,6 +1704,56 @@
}
}
},
"model.File": {
"type": "object",
"properties": {
"created_at": {
"type": "string"
},
"extension": {
"type": "string"
},
"hash": {
"type": "string"
},
"id": {
"type": "integer"
},
"last_referenced_at": {
"type": "string"
},
"metadata": {
"type": "string"
},
"mime_type": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"ref_count": {
"type": "integer"
},
"size": {
"type": "integer"
},
"status": {
"type": "integer"
},
"storage": {
"type": "string"
},
"updated_at": {
"type": "string"
},
"uploader_id": {
"type": "integer"
}
}
},
"model.Note": {
"type": "object",
"properties": {
+228
View File
@@ -72,6 +72,39 @@ definitions:
example: record not found
type: string
type: object
model.File:
properties:
created_at:
type: string
extension:
type: string
hash:
type: string
id:
type: integer
last_referenced_at:
type: string
metadata:
type: string
mime_type:
type: string
name:
type: string
path:
type: string
ref_count:
type: integer
size:
type: integer
status:
type: integer
storage:
type: string
updated_at:
type: string
uploader_id:
type: integer
type: object
model.Note:
properties:
content:
@@ -405,6 +438,129 @@ paths:
summary: Register
tags:
- public
/files:
post:
consumes:
- multipart/form-data
description: Upload a file (multipart field file). Content is deduplicated by
sha256; the returned file has ref_count 0 until a business reference is acquired.
Size limit from storage.max_size_mb.
parameters:
- description: File content
in: formData
name: file
required: true
type: file
produces:
- application/json
responses:
"201":
description: Created
schema:
$ref: '#/definitions/model.File'
"400":
description: invalid request or empty file
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"413":
description: file too large
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Upload a file
tags:
- user
/files/{id}:
delete:
description: Delete a file physically and keep the record with status 0; uploader
or admin only. Files still referenced (ref_count > 0) return 409.
parameters:
- description: File ID
example: 1
in: path
name: id
required: true
type: integer
produces:
- application/json
responses:
"204":
description: Deleted
"400":
description: invalid id
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: permission denied or account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"404":
description: record not found
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"409":
description: file is in use
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Delete a file
tags:
- user
get:
description: Public file content. Images, videos, audio, PDF and plain text
are served inline; other types are served as attachments. Disabled files return
404.
parameters:
- description: File ID
example: 1
in: path
name: id
required: true
type: integer
produces:
- application/octet-stream
responses:
"200":
description: OK
schema:
type: file
"400":
description: invalid id
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"404":
description: record not found
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
summary: Get file content
tags:
- public
/health:
get:
description: Check service and database connectivity; returns 503 when the database
@@ -487,6 +643,78 @@ paths:
summary: Update current user profile
tags:
- user
/me/avatar:
delete:
description: Clear the authenticated user's avatar and release the file reference
when it points to a local file.
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Delete current user avatar
tags:
- user
put:
consumes:
- multipart/form-data
description: Upload an image as the authenticated user's avatar (multipart field
file, image only). The avatar URL is stored on the user and the file reference
count is managed automatically.
parameters:
- description: Avatar image
in: formData
name: file
required: true
type: file
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/model.User'
"400":
description: invalid request, empty file, or not an image
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"401":
description: unauthorized or session expired
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"403":
description: account disabled
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"413":
description: file too large
schema:
$ref: '#/definitions/httpx.ErrorResponse'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/httpx.ErrorResponse'
security:
- BearerAuth: []
summary: Update current user avatar
tags:
- user
/notes:
get:
description: List notes ordered by id DESC. page starts at 1; page_size is 1-100,