diff --git a/AGENTS.md b/AGENTS.md index 5b93431d..68c7a57b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,242 +1,197 @@ -# Wavelet Agent Index +# Wavelet Agent 索引 -This file is the project-level guide for agents working in Wavelet. More -specialized workflows still live in `.agent/skills/`. +本文件是 Wavelet 项目中 Agent 工作的项目级指南。更具体的专门工作流仍保留在 `.agent/skills/` 中。 -## Always Read The Matching Skill +## 务必阅读匹配的 Skill -- `new-api`: use when adding or changing custom business APIs, handlers, service layer logic, or registering customized endpoints. -- `new-async-task`: use when adding or changing Asynq tasks, scheduled jobs, - task metadata, task payload validation, task logs, task retry behavior, or - Admin task APIs. -- `new-setting`: use when adding or changing startup config, database-backed - system/business/public settings, `/admin/system` parameters, or - `/admin/settings` graphical settings. -- `database-migration`: use when adding or changing database schema, indexes, - seed data, system config defaults, template defaults, default admin data, - goose SQL migrations, or the database upgrade flow. -- Go skills: use the focused `go-*` skills for Go implementation details such - as testing, error handling, packages, context, concurrency, logging, - documentation, and review. -- `shadcn`: use when adding, changing, or composing shadcn/ui components. +- `new-api`:在添加或修改自定义业务 API、Handler、服务层逻辑或注册自定义端点时使用。 +- `new-async-task`:在添加或修改 Asynq 任务、定时任务、任务元数据、任务负载验证、任务日志、任务重试行为或 Admin 任务 API 时使用。 +- `new-setting`:在添加或修改启动配置、基于数据库的系统/业务/公开设置、`/admin/system` 参数或 `/admin/settings` 图形化设置时使用。 +- `database-migration`:在添加或修改数据库 Schema、索引、Seed 数据、系统配置默认值、模板默认值、默认管理员数据、goose SQL 迁移或数据库升级流程时使用。 +- Go skills:使用针对性的 `go-*` skills 来获取 Go 实现细节,如测试、错误处理、包、Context、并发、日志、文档和审查。 +- `shadcn`:在添加、修改或组合 shadcn/ui 组件时使用。 -## Non-Negotiable Project Guardrails +## 不可逾越的项目红线 (Guardrails) -- Do not delete `frontend/node_modules`; reinstall with `pnpm install` if - dependencies need refreshing. -- Keep `internal/util/` framework-free. Do not import Gin, GORM, sessions, or - other HTTP/framework packages from `internal/util/` or its subpackages. -- Register all HTTP routes only in `internal/router/router.go`. -- Update Swagger (`make swagger`) when API handlers change. -- Run `make code-check` before submitting changes. +- 切勿删除 `frontend/node_modules`;如果需要刷新依赖,请使用 `pnpm install` 重新安装。 +- 保持 `internal/util/` 不引入任何框架。不要从 `internal/util/` 及其子包中导入 Gin、GORM、sessions 或其他 HTTP/框架包。 +- 所有 HTTP 路由仅在 `internal/router/router.go` 中注册。 +- 当 API Handler 发生变化时,更新 Swagger 文档(运行 `make swagger`)。 +- 在提交更改前运行 `make code-check`。 -## Quick Commands +## 常用命令 -| Command | When | +| 命令 | 适用场景 | | --- | --- | -| `make code-check` | Required before submit | -| `make build-test` | Functional build verification | -| `make swagger` | After adding/changing APIs | -| `make build-embedded` | Release binary with embedded frontend | -| `make license` | After adding Go files | -| `make license-check` | CI/license validation | +| `make code-check` | 提交前的必要检查 | +| `make build-test` | 功能性构建验证 | +| `make swagger` | 添加/修改 API 后 | +| `make build-embedded` | 发布带有内嵌前端的二进制文件 | +| `make license` | 添加 Go 文件后 | +| `make license-check` | CI/许可证验证 | -# Wavelet Project Development Guide +# Wavelet 项目开发指南 -Use this guide for ordinary Wavelet development. If the task is specifically -about Asynq/background/scheduled tasks, use `new-async-task` as the detailed -workflow. +本指南用于普通的 Wavelet 开发。如果任务是关于 Asynq/后台/定时任务的,请使用 `new-async-task` 作为详细的工作流。 -## Tech Stack +## 技术栈 -- Backend: Go 1.25+, Gin, GORM, PostgreSQL, optional ClickHouse, Redis, Asynq, - Cobra, Viper, Swaggo, OpenTelemetry, Zap, AWS SDK v2, Snowflake IDs. -- Frontend: Next.js App Router, TypeScript, Tailwind CSS, pnpm, shadcn/ui. +- 后端:Go 1.25+、Gin、GORM、PostgreSQL、可选 ClickHouse、Redis、Asynq、Cobra、Viper、Swaggo、OpenTelemetry、Zap、AWS SDK v2、Snowflake IDs。 +- 前端:Next.js App Router、TypeScript、Tailwind CSS、pnpm、shadcn/ui。 -## Directory Map +## 目录结构映射 -Top level: +顶层目录: -- `main.go`: program entry, delegates to `internal/cmd`. -- `config.example.yaml`: committed config template. Keep it updated when adding - config fields. -- `config.yaml`: local runtime config. Do not treat it as committed source. -- `docker/`: integrated, frontend-only, and backend-only Dockerfiles. -- `docs/`: generated Swagger docs. Do not hand edit generated files. -- `frontend/`: Next.js app. -- `internal/`: private Go backend code. -- `scripts/`: local and CI helper scripts. -- `support-files/`: auxiliary deployment files. +- `main.go`:程序入口,委派给 `internal/cmd`。 +- `config.example.yaml`:已提交的配置模板。在添加配置字段时保持更新。 +- `config.yaml`:本地运行时的配置文件。不要将其作为已提交的源码提交。 +- `docker/`:集成的、仅前端的和仅后端的 Dockerfile。 +- `docs/`:自动生成的 Swagger 文档。请勿手动编辑生成的文件。 +- `frontend/`:Next.js 应用。 +- `internal/`:私有 Go 后端代码。 +- `scripts/`:本地和 CI 辅助脚本。 +- `support-files/`:辅助部署文件。 -Backend: +后端目录: -- `internal/cmd/`: Cobra commands for API, worker, scheduler, root init. -- `internal/config/`: Viper loading and config structs. Runtime code should use - `config.Config.
.`. -- `internal/router/`: the only HTTP route registration point. -- `internal/apps/`: feature modules and HTTP handlers. -- `internal/model/`: GORM entities and model-level business methods. -- `internal/db/`: PostgreSQL, Redis, ClickHouse, GORM logging, ID generation, - and goose SQL migration wiring. -- `internal/storage/`: S3-compatible storage and cache abstraction. -- `internal/task/`: Asynq task framework; see `new-async-task` for changes. -- `internal/service/`: complex business services when handlers/models are too - narrow a home. -- `internal/common/`: shared response, bind, constants, and common errors. -- `internal/util/`: pure utilities with no framework imports. -- `internal/logger/`: Zap and OTel logging helpers. -- `internal/listener/`: event listeners and message/webhook consumers. -- `internal/otel_trace/`: tracing helpers. +- `internal/cmd/`:用于 API、worker、scheduler、root init 的 Cobra 命令。 +- `internal/config/`:Viper 加载和配置结构体。运行时代码应使用 `config.Config.
.`。 +- `internal/router/`:唯一的 HTTP 路由注册点。 +- `internal/apps/`:功能模块和 HTTP Handler。 +- `internal/model/`:GORM 实体和模型级业务方法。 +- `internal/db/`:PostgreSQL、Redis、ClickHouse、GORM 日志、ID 生成和 goose SQL 迁移的布线。 +- `internal/storage/`:兼容 S3 的存储和缓存抽象。 +- `internal/task/`:Asynq 任务框架;参见 `new-async-task` 了解变更。 +- `internal/service/`:当 Handler/Model 层次过于狭窄时使用的复杂业务服务。 +- `internal/common/`:共享的响应、绑定(bind)、常量以及通用错误。 +- `internal/util/`:纯实用工具,不导入任何框架。 +- `internal/logger/`:Zap 和 OTel 日志助手。 +- `internal/listener/`:事件监听器和消息/Webhook 消费者。 +- `internal/otel_trace/`:链路追踪(tracing)助手。 -Frontend: +前端目录: -- `frontend/app/`: App Router pages, route groups, root layout, globals. -- `frontend/components/ui/`: shadcn/ui base components. -- `frontend/components/common/`: cross-page business components. -- `frontend/components/layout/`: Header, Sidebar, Footer, app layout pieces. -- `frontend/components/auth/`, `home/`, `animate-ui/`, `providers/`: scoped UI. -- `frontend/contexts/`, `hooks/`, `lib/`, `types/`, `public/`: shared state, - hooks, clients/utilities, TypeScript types, static assets. +- `frontend/app/`:App Router 页面、路由组、根布局、全局配置。 +- `frontend/components/ui/`:shadcn/ui 基础组件。 +- `frontend/components/common/`:跨页面的业务组件。 +- `frontend/components/layout/`:Header、Sidebar、Footer 等应用布局组件。 +- `frontend/components/auth/`、`home/`、`animate-ui/`、`providers/`:特定作用域的 UI 组件。 +- `frontend/contexts/`、`hooks/`、`lib/`、`types/`、`public/`:共享状态、Hook、客户端/实用工具、TypeScript 类型、静态资产。 -Important common components: +重要的公共组件: -- `components/common/admin/tasks.tsx`: task dispatch UI. -- `components/common/admin/task-executions.tsx`: task execution log/retry UI. -- `components/common/admin/system.tsx`: system config management. -- `components/common/admin/users.tsx`: user management. -- `components/common/general/manage-pannel.tsx`: generic list/detail manager. -- `components/common/general/password-dialog.tsx`: sensitive-action password - confirmation dialog. -- `components/common/settings/system-settings.tsx`: admin system settings. +- `components/common/admin/tasks.tsx`:任务分发 UI。 +- `components/common/admin/task-executions.tsx`:任务执行日志/重试 UI。 +- `components/common/admin/system.tsx`:系统配置管理。 +- `components/common/admin/users.tsx`:用户管理。 +- `components/common/general/manage-pannel.tsx`:通用的列表/详情管理器。 +- `components/common/general/password-dialog.tsx`:敏感操作密码确认对话框。 +- `components/common/settings/system-settings.tsx`:管理员系统设置。 -## Backend Rules +## 后端规则 -Naming: +命名规范: -- Go packages and files use lowercase snake words: `auth_source`, - `postgres_logger.go`. -- Exported Go identifiers use PascalCase; unexported identifiers use camelCase. -- Request/response structs use camelCase with suffixes like - `listUsersRequest` and `listUsersResponse`. -- Error message constants are camelCase string `const` values, not package-level - `error` values. -- YAML config keys use lowercase snake case. +- Go 包和文件使用小写蛇形命名(lowercase snake case):如 `auth_source`、`postgres_logger.go`。 +- 导出的 Go 标识符使用 PascalCase;未导出的标识符使用 camelCase。 +- 请求/响应结构体使用 camelCase 并带有后缀,例如 `listUsersRequest` 和 `listUsersResponse`。 +- 错误消息常量是 camelCase 字符串 `const`值,而不是包级别的 `error` 值。 +- YAML 配置键使用小写蛇形命名(lowercase snake case)。 -Handlers: +Handler 规范: -- Handler names are verb + noun, for example `ListUsers`. -- Bind with `ShouldBindQuery` or `ShouldBindJSON`. -- Return success through `util.OK(data)`, `util.OKNil()`, or - `response.RespondSuccess`. -- Return failures with `util.Err(msg)` or `response.RespondFailure`. -- API responses must have the outer shape `{ "error_msg": "", "data": ... }`. -- Pagination responses use `{ "total": 0, "results": [] }` under `data`. -- Every HTTP API needs complete Swagger comments; run `make swagger` after API - changes. +- Handler 命名为 动词 + 名词,例如 `ListUsers`。 +- 使用 `ShouldBindQuery` 或 `ShouldBindJSON` 进行绑定。 +- 成功时通过 `util.OK(data)`、`util.OKNil()` 或 `response.RespondSuccess` 返回。 +- 失败时通过 `util.Err(msg)` 或 `response.RespondFailure` 返回。 +- API 响应的外层结构必须为 `{ "error_msg": "", "data": ... }`。 +- 分页响应在 `data` 下使用 `{ "total": 0, "results": [] }`。 +- 每个 HTTP API 都需要有完整的 Swagger 注释;在 API 变更后运行 `make swagger`。 错误处理与日志: -- 任何关键错误在被吞掉、转换为通用响应,或由后台 worker 忽略之前, - 都必须通过 `internal/logger` 打印日志。 -- 禁止用 `_ = ...` 静默丢弃重要错误。如果某个错误因为 best-effort - 操作或确认无害而需要忽略,必须添加简短注释说明原因。 -- Handler 可以返回对用户安全的错误信息,但如果底层运行错误对生产问题 - 排查有价值,仍然必须记录日志。 +- 任何关键错误在被吞掉、转换为通用响应,或由后台 worker 忽略之前,都必须通过 `internal/logger` 打印日志。 +- 禁止用 `_ = ...` 静默丢弃重要错误。如果某个错误因为 best-effort 操作或确认无害而需要忽略,必须添加简短注释说明原因。 +- Handler 可以返回对用户安全的错误信息,但如果底层运行错误对生产问题排查有价值,仍然必须记录日志。 - 避免重复刷日志:在真正处理或抑制错误的边界记录一次,然后返回或响应。 -Routes and modules: +路由与模块: -- Register routes only in `internal/router/router.go`. -- In `internal/apps//`, use: - - `routers.go` or `controllers.go` for HTTP handlers. - - `middlewares.go` for module-specific middleware. - - `errs.go` for string error constants only. - - `constants.go` for non-error business constants. -- For Admin modules, prefer `internal/apps/admin//`. -- If a handler file exceeds 600 lines, contains complex multi-step logic, or - mixes independent domains, split business logic into `logic.go` or - `logics.go`. Keep `routers.go` to binding, calling logic, and responding. +- 仅在 `internal/router/router.go` 中注册路由。 +- 在 `internal/apps//` 中,使用: + - `routers.go` 或 `controllers.go` 作为 HTTP Handler。 + - `middlewares.go` 作为模块特定的中间件。 + - `errs.go` 仅包含字符串错误常量。 + - `constants.go` 包含非错误的业务常量。 +- 对于管理(Admin)模块,首选 `internal/apps/admin//`。 +- 如果 Handler 文件超过 600 行、包含复杂的多个步骤逻辑,或混合了独立领域,请将业务逻辑拆分到 `logic.go` 或 `logics.go` 中。保持 `routers.go` 仅用于绑定、调用逻辑和响应。 -Middleware: +中间件: -- Global middleware belongs in router setup: `gin.Recovery()`, - `otelgin.Middleware()`, logger middleware, and session middleware. -- Use `oauth.LoginRequired()` for logged-in route groups. -- Use `admin.LoginAdminRequired()` for Admin route groups. +- 全局中间件属于路由设置:`gin.Recovery()`、`otelgin.Middleware()`、日志中间件和 session 中间件。 +- 对于登录路由组,使用 `oauth.LoginRequired()`。 +- 对于管理路由组,使用 `admin.LoginAdminRequired()`。 -Config: +配置管理: -- Runtime code reads config from `config.Config`, never directly from - `os.Getenv()`. -- When adding config, update both `config.example.yaml` and - `internal/config/model.go`. +- 运行时代码从 `config.Config` 中读取配置,绝对不要直接从 `os.Getenv()` 中读取。 +- 当添加配置时,同时更新 `config.example.yaml` 和 `internal/config/model.go`。 -Database: +数据库操作: -- Simple queries may use GORM directly from the model layer. -- Admin code should prefer `db.DB(ctx)` to get tracing-aware DB access. -- Do not put complex SQL in handlers; move it to `internal/model/` or - `internal/service/`. -- Use goose SQL migrations under `internal/db/migrator/goose/`; do not add - GORM AutoMigrate-based schema upgrades. -- Do not create physical database foreign keys. Add explicit indexes for - relation fields instead. -- Database defaults must match Go model zero values (`nil`, `0`, `false`, `""`) - to avoid surprising inserts. +- 简单查询可以直接从 model 层使用 GORM。 +- 管理员代码应首选 `db.DB(ctx)` 以获得链路追踪感知的 DB 访问。 +- 不要在 Handler 中放置复杂的 SQL;将其移至 `internal/model/` 或 `internal/service/`。 +- 在 `internal/db/migrator/goose/` 下使用 goose SQL 迁移;不要添加基于 GORM AutoMigrate 的 Schema 升级。 +- 不要创建物理数据库外键。改为关系字段添加显式索引。 +- 数据库默认值必须与 Go 模型零值(`nil`、`0`、`false`、`""`)匹配,以避免意外的插入。 -Strict dependency guard: +严格依赖防线: -- `internal/util/` and its subpackages must stay framework-free. -- Do not import `github.com/gin-gonic/gin`, `gorm.io/gorm`, - `github.com/gin-contrib/sessions`, or HTTP middleware/framework packages from - `internal/util/`. -- If utility logic needs web glue, keep pure validation/calculation in - `internal/util/` and put Gin middleware/response handling in `internal/apps/`. +- `internal/util/` 及其子包必须保持无框架依赖。 +- 不要从 `internal/util/` 中导入 `github.com/gin-gonic/gin`、`gorm.io/gorm`、`github.com/gin-contrib/sessions` 或 HTTP 中间件/框架包。 +- 如果实用工具逻辑需要 web 胶水,请将纯验证/计算保留在 `internal/util/` 中,并将 Gin 中间件/响应处理放在 `internal/apps/` 中。 -Admin module workflow: +管理(Admin)模块工作流: -1. Define or extend models in `internal/model/`. -2. Add goose SQL migrations under `internal/db/migrator/goose/`. -3. Create `internal/apps/admin//routers.go` and optional `errs.go`. -4. Register routes in `internal/router/router.go`. -5. Run `make swagger`. +1. 在 `internal/model/` 中定义或扩展模型。 +2. 在 `internal/db/migrator/goose/` 下添加 goose SQL 迁移。 +3. 创建 `internal/apps/admin//routers.go` 和可选的 `errs.go`。 +4. 在 `internal/router/router.go` 中注册路由。 +5. 运行 `make swagger`。 -## Frontend Rules +## 前端规则 -Styling: + -- shadcn/ui base components should use their `variant` system and global CSS - variables. Do not hardcode colors, backgrounds, or shadows in business - `className` when a component variant should own the look. -- If an existing variant is insufficient, extend the shadcn/ui component - variant instead of hardcoding one-off colors. -- Use Lucide icons for common icon needs. Put custom icons in - `frontend/components/icons/` as named exports. +# Next.js: 在编码前务必阅读文档 -Page width: +在进行任何 Next.js 工作之前,请在 `node_modules/next/dist/docs/` 中找到并阅读相关文档。您的训练数据已过时 —— 这些文档是唯一的真理来源。 -- Page root containers must support full width. Use `w-full`. -- Do not hardcode page-level max widths like `max-w-6xl` or `max-w-4xl`; the - main layout owns the normal/full-width constraint. + -Component placement: +样式规范: -- Cross-page business components belong in `frontend/components/common/`. -- shadcn/ui primitives belong in `frontend/components/ui/`. -- Route/page-specific components belong in the closest feature directory. +- shadcn/ui 基础组件应该使用它们的 `variant` 系统和全局 CSS 变量。当组件的变体(variant)应该拥有某种外观时,不要在业务 `className` 中硬编码颜色、背景或阴影。 +- 如果现有的变体不足以满足需求,请扩展 shadcn/ui 组件的变体,而不是硬编码一次性的颜色。 +- 使用 Lucide 图标来满足常见的图标需求。将自定义图标作为命名导出放在 `frontend/components/icons/` 中。 -Type safety: +页面宽度: -- Do not use `any`. -- Use `unknown` only with explicit narrowing or type assertions before use. -- Use `never` sparingly and document why when it is non-obvious. -- Frontend changes must pass TypeScript and ESLint checks. +- 页面根容器必须支持全宽。使用 `w-full`。 +- 不要硬编码页面级的最大宽度,如 `max-w-6xl` 或 `max-w-4xl`;主布局(main layout)拥有正常/全宽的限制。 -Services: +组件放置: -- Frontend API access goes through service classes and the exported `services` - object. -- Create new services as: +- 跨页面的业务组件属于 `frontend/components/common/`。 +- shadcn/ui 原生组件(primitives)属于 `frontend/components/ui/`。 +- 特定于路由/页面的组件放在最邻近的特征(feature)目录中。 + +服务类(Services): + +- 前端 API 访问通过服务类和导出的 `services` 对象进行。 +- 新增服务结构如下: ```text frontend/lib/services// @@ -245,26 +200,16 @@ frontend/lib/services// index.ts ``` -- Service classes extend `BaseService`, define `basePath`, and expose typed - static methods. -- Register the new service in `frontend/lib/services/index.ts`. +- 服务类继承 `BaseService`,定义 `basePath`,并暴露有类型的静态方法。 +- 在 `frontend/lib/services/index.ts` 中注册新服务。 -## Quality Gates +## 质量门禁 (Quality Gates) -- `make code-check`: required before submit; frontend typecheck + ESLint and - backend golangci-lint. -- `make build-test`: build verification for frontend and Go backend. -- `make swagger`: regenerate Swagger after API changes. -- `make build-embedded`: release binary with frontend static export embedded. -- `make license`: run after adding Go files. -- `make license-check`: validate Go license headers. +- `make code-check`:提交前的必要检查;前端类型检查 + ESLint 以及后端 golangci-lint。 +- `make build-test`:前端和 Go 后端的构建验证。 +- `make swagger`:API 变更后重新生成 Swagger。 +- `make build-embedded`:发布带有前端静态导出嵌入的二进制文件。 +- `make license`:添加 Go 文件后运行。 +- `make license-check`:验证 Go 许可证头。 -Never delete `frontend/node_modules`; refresh dependencies with `pnpm install`. - - - -# Next.js: ALWAYS read docs before coding - -Before any Next.js work, find and read the relevant doc in `node_modules/next/dist/docs/`. Your training data is outdated — the docs are the source of truth. - - +切勿删除 `frontend/node_modules`;使用 `pnpm install` 刷新依赖。 diff --git a/docs/docs.go b/docs/docs.go index 1b758a10..5fa15687 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -2353,6 +2353,64 @@ const docTemplate = `{ } } }, + "/api/v1/admin/uploads/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统中所有已上传文件所拥有的业务类型,并合并默认内置类型(avatar, attachment, doc, generic)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件业务类型列表", + "responses": { + "200": { + "description": "业务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -4185,6 +4243,12 @@ const docTemplate = `{ "$ref": "#/definitions/util.ResponseAny" } }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, "404": { "description": "文件未找到", "schema": { @@ -4559,9 +4623,6 @@ const docTemplate = `{ "is_admin": { "type": "boolean" }, - "last_used_at": { - "type": "string" - }, "masked_token": { "type": "string" }, diff --git a/docs/swagger.json b/docs/swagger.json index 70f4a70e..cde5fbf3 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -2346,6 +2346,64 @@ } } }, + "/api/v1/admin/uploads/types": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回系统中所有已上传文件所拥有的业务类型,并合并默认内置类型(avatar, attachment, doc, generic)", + "produces": [ + "application/json" + ], + "tags": [ + "admin" + ], + "summary": "获取文件业务类型列表", + "responses": { + "200": { + "description": "业务类型列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "string" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "403": { + "description": "无管理员权限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/admin/users": { "get": { "security": [ @@ -4178,6 +4236,12 @@ "$ref": "#/definitions/util.ResponseAny" } }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, "404": { "description": "文件未找到", "schema": { @@ -4552,9 +4616,6 @@ "is_admin": { "type": "boolean" }, - "last_used_at": { - "type": "string" - }, "masked_token": { "type": "string" }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 68dcc41a..200b97ff 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -222,8 +222,6 @@ definitions: type: integer is_admin: type: boolean - last_used_at: - type: string masked_token: type: string name: @@ -2418,6 +2416,40 @@ paths: summary: 更新模板 tags: - admin + /api/v1/admin/uploads/types: + get: + description: 返回系统中所有已上传文件所拥有的业务类型,并合并默认内置类型(avatar, attachment, doc, generic) + produces: + - application/json + responses: + "200": + description: 业务类型列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + type: string + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "403": + description: 无管理员权限 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取文件业务类型列表 + tags: + - admin /api/v1/admin/users: get: description: 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 @@ -3512,6 +3544,10 @@ paths: description: 文件 ID 格式错误 schema: $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' "404": description: 文件未找到 schema: diff --git a/frontend/components/common/settings/operation-tab.tsx b/frontend/components/common/settings/operation-tab.tsx index 6397d3c6..8fbf03c2 100644 --- a/frontend/components/common/settings/operation-tab.tsx +++ b/frontend/components/common/settings/operation-tab.tsx @@ -1,9 +1,11 @@ "use client" -import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query" -import {Globe, Search} from "lucide-react" +import {useMemo} from "react" +import {useMutation, useQuery, useQueryClient, type UseQueryResult} from "@tanstack/react-query" +import {KeyRound, ShieldAlert, X} from "lucide-react" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" -import {Switch} from "@/components/ui/switch" +import {Badge} from "@/components/ui/badge" +import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from "@/components/ui/select" import {AdminService} from "@/lib/services" import type {SystemConfig} from "@/lib/services/admin" import {TemplatesManager} from "./templates" @@ -17,62 +19,144 @@ interface OperationTabProps { export function OperationTab({ configs, systemConfigsQuery }: OperationTabProps) { const queryClient = useQueryClient() - const updateConfigMutation = useMutation({ - mutationFn: async ({ key, value }: { key: string; value: boolean }) => { - const config = configs[key] + const uploadTypesQuery = useQuery({ + queryKey: ["admin", "upload-types"], + queryFn: () => AdminService.listUploadTypes(), + }) + + const updateWhitelistMutation = useMutation({ + mutationFn: async (newValue: string) => { + const config = configs["file_access_whitelist"] if (!config) { - throw new Error(`缺少配置项: ${key}`) + throw new Error("缺少配置项: file_access_whitelist") } - await AdminService.updateSystemConfig(key, { - value: value ? "true" : "false", + await AdminService.updateSystemConfig("file_access_whitelist", { + value: newValue, description: config.description, }) }, onSuccess: async () => { await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) await queryClient.invalidateQueries({ queryKey: ["public-config"] }) - toast.success("运营配置已更新") + toast.success("文件访问白名单已更新") }, onError: (error: Error) => { - toast.error(error.message || "更新配置失败") + toast.error(error.message || "更新白名单失败") }, }) - const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true" + const whitelistConfig = configs["file_access_whitelist"] + const currentWhitelist = useMemo(() => { + if (!whitelistConfig?.value) return ["avatar"] + try { + const parsed = JSON.parse(whitelistConfig.value) + if (Array.isArray(parsed)) return parsed + } catch { + // 降级支持逗号分隔解析 + return whitelistConfig.value.split(",").map(s => s.trim()).filter(Boolean) + } + return ["avatar"] + }, [whitelistConfig?.value]) + + const handleAddType = (type: string) => { + if (!type || currentWhitelist.includes(type)) return + const newWhitelist = [...currentWhitelist, type] + updateWhitelistMutation.mutate(JSON.stringify(newWhitelist)) + } + + const handleRemoveType = (typeToRemove: string) => { + const newWhitelist = currentWhitelist.filter(t => t !== typeToRemove) + updateWhitelistMutation.mutate(JSON.stringify(newWhitelist)) + } + + const availableTypes = useMemo(() => { + const types = uploadTypesQuery.data ?? [] + return types.map(t => { + let label = t + if (t === "avatar") label = "头像 (avatar)" + else if (t === "attachment") label = "附件 (attachment)" + else if (t === "doc") label = "文档 (doc)" + else if (t === "generic") label = "通用 (generic)" + return { value: t, label } + }) + }, [uploadTypesQuery.data]) return (
- {/* 搜索引擎检索设置 */} + + {/* 文件访问白名单设置 */}
- +
- SEO 与搜索引擎检索 - 配置站点是否允许被搜索引擎抓取和检索 + 文件访问权限控制 + 配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。
- -
-
-
- - 允许被搜索引擎检索 -
-

- 默认关闭。关闭后系统将自动下发 noindex 指令并限制 robots.txt,禁止搜索引擎爬虫抓取本站页面。 -

+ +
+
+ 添加免鉴权类型: + +
+ + {/* 当前白名单列表 */} +
+
+ + 当前免鉴权列表 +
+ + {currentWhitelist.length > 0 ? ( +
+ {currentWhitelist.map(type => ( + + {availableTypes.find(t => t.value === type)?.label || type} + + + ))} +
+ ) : ( +

+ 白名单已空,所有类型文件的访问都将需要登录。 +

+ )}
- - updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked }) - } - />
diff --git a/frontend/components/common/settings/system-tab.tsx b/frontend/components/common/settings/system-tab.tsx index ce3a5ebd..0018f386 100644 --- a/frontend/components/common/settings/system-tab.tsx +++ b/frontend/components/common/settings/system-tab.tsx @@ -2,13 +2,15 @@ import {useEffect, useState} from "react" import {useMutation, useQueryClient, type UseQueryResult} from "@tanstack/react-query" -import {Loader2, Mail, Server} from "lucide-react" +import {Globe, Info, Loader2, Mail, Search, Server, Sparkles} from "lucide-react" import {Button} from "@/components/ui/button" import {Card, CardContent, CardDescription, CardHeader, CardTitle} from "@/components/ui/card" import {Input} from "@/components/ui/input" import {Label} from "@/components/ui/label" +import {Switch} from "@/components/ui/switch" import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from "@/components/ui/dialog" +import {Badge} from "@/components/ui/badge" import {AdminService} from "@/lib/services" import type {SystemConfig} from "@/lib/services/admin" import {toast} from "sonner" @@ -41,6 +43,34 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) { } }, [systemConfigsQuery.data, configs]) + const handleDetectAddress = () => { + if (typeof window !== "undefined") { + setServerAddress(window.location.origin) + toast.success("已自动获取当前域名并填充") + } + } + + const updateConfigMutation = useMutation({ + mutationFn: async ({ key, value }: { key: string; value: boolean }) => { + const config = configs[key] + if (!config) { + throw new Error(`缺少配置项: ${key}`) + } + await AdminService.updateSystemConfig(key, { + value: value ? "true" : "false", + description: config.description, + }) + }, + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: ["admin", "system-configs"] }) + await queryClient.invalidateQueries({ queryKey: ["public-config"] }) + toast.success("配置已更新") + }, + onError: (error: Error) => { + toast.error(error.message || "更新配置失败") + }, + }) + const saveSystemMutation = useMutation({ mutationFn: async () => { const currentCfg = configs["server_address"] @@ -143,54 +173,142 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) { testSmtpMutation.mutate() } + const indexingEnabled = configs["search_engine_indexing_enabled"]?.value === "true" + return ( -
+
{/* 通用设置 */} - - -
-
- + + +
+
+
- 通用设置 - 配置系统的全局通用参数 + 通用设置 + 配置系统的全局网络通信访问限制与搜索引擎的公开检索收录参数
- -
-
- - setServerAddress(e.target.value)} - placeholder="例如: https://example.com" - className="bg-card border-dashed text-xs" - /> -

- 这里可以编辑更改服务器地址。默认不设定,允许从任意源(*)访问 API,此时存在跨域安全风险;如果手动设置服务器地址,CORS 允许源将更新为该地址,消除跨域安全隐患。 -

+ +
+ + {/* 跨域源与服务器地址 */} + +
+
+
+ + 访问域名与跨域来源限制 +
+ +
+
+
+ +
+
+ setServerAddress(e.target.value)} + placeholder="例如: https://example.com" + className="bg-zinc-50/50 dark:bg-zinc-900/50 border-zinc-200 dark:border-zinc-800 text-xs focus-visible:ring-1 focus-visible:ring-indigo-500 transition-all duration-200 h-9.5" + /> +
+
+ +

+ 配置 API 的对外服务访问域名。留空则允许任意源访问(CORS 将开放 `*`,有安全风险)。若配置具体域名,将激活同源及 CORS 白名单验证保护。 +

+
+
+
+
+ +
+ + + {/* SEO 搜索引擎抓取 */} +
+
+
+
+ + 搜索引擎抓取检索 (SEO) +
+ + + {indexingEnabled ? "已启用索引" : "已屏蔽检索"} + +
+
+ 站点检索可见性开关 +
+ +

+ 控制本系统是否向主流搜索引擎(如 Google、Baidu、Bing)开放公开索引。关闭时,系统响应的 HTML 头部会自动注入 meta robots 标签,防止爬虫收录。 +

+
+
+
+ +
+
+ + {indexingEnabled ? "允许爬虫访问与收录" : "全面屏蔽外部搜索"} + + + {indexingEnabled ? "爬虫可自由搜集并抓取网站内容" : "已拦截所有网络爬虫的收录请求"} + +
+ + updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked }) + } + className="data-[state=checked]:bg-emerald-500 dark:data-[state=checked]:bg-emerald-600 focus-visible:ring-emerald-500" + /> +
-
- -
- + +
@@ -370,3 +488,4 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
) } + diff --git a/frontend/lib/services/admin/admin.service.ts b/frontend/lib/services/admin/admin.service.ts index 0897639b..4a9d401e 100644 --- a/frontend/lib/services/admin/admin.service.ts +++ b/frontend/lib/services/admin/admin.service.ts @@ -133,6 +133,14 @@ export class AdminService extends BaseService { }): Promise<{ success: boolean; log: string; error: string }> { return this.post<{ success: boolean; log: string; error: string }>('/system-configs/smtp/test', request); } + + /** + * 获取所有已存在的文件业务类型及默认内置类型列表 + * @returns 业务类型列表 + */ + static async listUploadTypes(): Promise { + return this.get('/uploads/types'); + } diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 95ecc15b..b4f16d7c 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -137,9 +137,9 @@ func TestListSystemConfigs(t *testing.T) { var configs []model.SystemConfig _ = json.Unmarshal(dataBytes, &configs) - // Defaults seed 23 configurations - if len(configs) != 23 { - t.Errorf("expected 23 default configs, got %d", len(configs)) + // Defaults seed 24 configurations + if len(configs) != 24 { + t.Errorf("expected 24 default configs, got %d", len(configs)) } }) diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index ae876fa6..8c0bcd42 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -5,6 +5,7 @@ package oauth import ( + "errors" "net/http" "github.com/Rain-kl/Wavelet/internal/common" @@ -26,6 +27,57 @@ type loginRequiredAuditLog struct { Referer string `json:"referer"` } +// GetUserFromRequest 校验 Access Token 或 Session 并返回用户对象,如果未登录或用户失效则返回 error +func GetUserFromRequest(c *gin.Context) (*model.User, error) { + ctx := c.Request.Context() + + // check token in headers + tokenStr := c.GetHeader("X-Access-Token") + if tokenStr == "" { + authHeader := c.GetHeader("Authorization") + if len(authHeader) > 7 && authHeader[:7] == "Bearer " { + tokenStr = authHeader[7:] + } + } + + var user model.User + var authenticated bool + var tokenAuth bool + var tokenAdmin bool + + if tokenStr != "" { + tokenHash := model.HashToken(tokenStr) + var tokenRecord model.AccessToken + if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil { + if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil { + authenticated = true + tokenAuth = true + tokenAdmin = tokenRecord.IsAdmin + } + } + } + + if !authenticated { + // load user from session + userID := GetUserIDFromContext(c) + if userID <= 0 { + return nil, errors.New("unauthorized") + } + + // load user from db to make sure is active + tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user) + if tx.Error != nil { + return nil, tx.Error + } + } + + // set keys in context + util.SetToContext(c, TokenAuthKey, tokenAuth) + util.SetToContext(c, TokenAdminKey, tokenAdmin) + + return &user, nil +} + // LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session func LoginRequired() gin.HandlerFunc { return func(c *gin.Context) { @@ -33,55 +85,17 @@ func LoginRequired() gin.HandlerFunc { ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired") defer span.End() - // check token in headers - tokenStr := c.GetHeader("X-Access-Token") - if tokenStr == "" { - authHeader := c.GetHeader("Authorization") - if len(authHeader) > 7 && authHeader[:7] == "Bearer " { - tokenStr = authHeader[7:] - } - } - - var user model.User - var authenticated bool - var tokenAuth bool - var tokenAdmin bool - - if tokenStr != "" { - tokenHash := model.HashToken(tokenStr) - var tokenRecord model.AccessToken - if err := db.DB(ctx).Where("token_hash = ?", tokenHash).First(&tokenRecord).Error; err == nil { - if err := db.DB(ctx).Where("id = ? AND is_active = ?", tokenRecord.UserID, true).First(&user).Error; err == nil { - authenticated = true - tokenAuth = true - tokenAdmin = tokenRecord.IsAdmin - } - } - } - - if !authenticated { - // load user from session - userID := GetUserIDFromContext(c) - if userID <= 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) - return - } - - // load user from db to make sure is active - tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user) - if tx.Error != nil { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) - return - } + user, err := GetUserFromRequest(c) + if err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) + return } // log - LogForAudit(ctx, &user, c) + LogForAudit(ctx, user, c) // set user info - util.SetToContext(c, UserObjKey, &user) - util.SetToContext(c, TokenAuthKey, tokenAuth) - util.SetToContext(c, TokenAdminKey, tokenAdmin) + util.SetToContext(c, UserObjKey, user) // next c.Next() diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 86bfa095..d9e45b90 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -5,10 +5,14 @@ package upload import ( + "encoding/json" "errors" "net/http" "strconv" + "strings" + "github.com/Rain-kl/Wavelet/internal/apps/oauth" + "github.com/Rain-kl/Wavelet/internal/common" "github.com/Rain-kl/Wavelet/internal/db" "github.com/Rain-kl/Wavelet/internal/model" "github.com/Rain-kl/Wavelet/internal/storage" @@ -24,6 +28,7 @@ import ( // @Param id path string true "文件 ID" // @Success 200 {file} file "成功获取文件内容" // @Failure 400 {object} util.ResponseAny "文件 ID 格式错误" +// @Failure 401 {object} util.ResponseAny "未登录" // @Failure 404 {object} util.ResponseAny "文件未找到" // @Failure 500 {object} util.ResponseAny "服务内部错误" // @Router /f/{id} [get] @@ -50,6 +55,12 @@ func ServeFileByID(c *gin.Context) { return } + // 校验业务白名单与访问权限 + if err := checkFileAccessPermission(c, upload.Type); err != nil { + c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) + return + } + if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) { c.File(upload.FilePath) return @@ -74,3 +85,40 @@ func ServeFileByID(c *gin.Context) { // Respond with the file content c.DataFromReader(http.StatusOK, obj.ContentLength, obj.ContentType, obj.Body, nil) } + +// checkFileAccessPermission 校验文件是否可以被当前请求访问 +func checkFileAccessPermission(c *gin.Context, uploadType string) error { + var sc model.SystemConfig + var whitelist []string + if err := sc.GetByKey(c.Request.Context(), model.ConfigKeyFileAccessWhitelist); err == nil && sc.Value != "" { + if err := json.Unmarshal([]byte(sc.Value), &whitelist); err != nil { + // 降级使用逗号分隔解析 + parts := strings.Split(sc.Value, ",") + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + whitelist = append(whitelist, p) + } + } + } + } else { + // 默认兜底白名单为 avatar + whitelist = []string{"avatar"} + } + + inWhitelist := false + for _, w := range whitelist { + if strings.EqualFold(w, uploadType) { + inWhitelist = true + break + } + } + + if !inWhitelist { + // 必须进行鉴权 + if _, err := oauth.GetUserFromRequest(c); err != nil { + return err + } + } + return nil +} diff --git a/internal/apps/upload/file_server_test.go b/internal/apps/upload/file_server_test.go new file mode 100644 index 00000000..23a14646 --- /dev/null +++ b/internal/apps/upload/file_server_test.go @@ -0,0 +1,211 @@ +// Copyright 2025 linux.do +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package upload + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/Rain-kl/Wavelet/internal/common" + "github.com/Rain-kl/Wavelet/internal/model" + "github.com/Rain-kl/Wavelet/internal/testhelper" + "github.com/gin-contrib/sessions" + "github.com/gin-contrib/sessions/cookie" + "github.com/gin-gonic/gin" +) + +func TestServeFileByIDAccessControl(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Ensure uploads dir is cleaned up + defer func() { _ = os.RemoveAll("uploads") }() + + // Create a user in DB + user := model.User{ + ID: 12345, + Username: "file_test_user", + IsActive: true, + } + if err := dbConn.Create(&user).Error; err != nil { + t.Fatalf("failed to create user: %v", err) + } + + // Create an access token for this user + tokenStr := "test-secret-token-123" + tokenHash := model.HashToken(tokenStr) + tokenRecord := model.AccessToken{ + UserID: user.ID, + Name: "test_token", + TokenHash: tokenHash, + } + if err := dbConn.Create(&tokenRecord).Error; err != nil { + t.Fatalf("failed to create token: %v", err) + } + + // Create two files: one in whitelist (avatar), one not in whitelist (attachment) + avatarFile := model.Upload{ + ID: 8001, + UserID: user.ID, + FileName: "avatar.png", + FilePath: "uploads/avatar.png", + FileSize: 5, + MimeType: "image/png", + Extension: "png", + StorageDriver: "local", + Type: "avatar", + Status: model.UploadStatusUsed, + } + attachmentFile := model.Upload{ + ID: 8002, + UserID: user.ID, + FileName: "doc.pdf", + FilePath: "uploads/doc.pdf", + FileSize: 5, + MimeType: "application/pdf", + Extension: "pdf", + StorageDriver: "local", + Type: "attachment", + Status: model.UploadStatusUsed, + } + + _ = os.MkdirAll("uploads", 0755) + _ = os.WriteFile(avatarFile.FilePath, []byte("image"), 0644) + _ = os.WriteFile(attachmentFile.FilePath, []byte("bytes"), 0644) + + dbConn.Create(&avatarFile) + dbConn.Create(&attachmentFile) + + // Set up router + gin.SetMode(gin.TestMode) + r := gin.New() + store := cookie.NewStore([]byte("secret")) + r.Use(sessions.Sessions("test_session", store)) + r.GET("/f/:id", ServeFileByID) + + t.Run("whitelisted file type (avatar) accessed without authentication", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8001", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "image" { + t.Errorf("expected 'image', got %q", w.Body.String()) + } + }) + + t.Run("non-whitelisted file type (attachment) accessed without authentication returns 401", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8002", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusUnauthorized { + t.Errorf("expected 401, got %d. Body: %s", w.Code, w.Body.String()) + } + + var body map[string]any + if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + if body["error_msg"] != common.UnAuthorized { + t.Errorf("expected error_msg %q, got %v", common.UnAuthorized, body["error_msg"]) + } + }) + + t.Run("non-whitelisted file type (attachment) accessed with valid token succeeds", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/8002", nil) + req.Header.Set("X-Access-Token", tokenStr) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Errorf("expected 200, got %d. Body: %s", w.Code, w.Body.String()) + } + if w.Body.String() != "bytes" { + t.Errorf("expected 'bytes', got %q", w.Body.String()) + } + }) + + t.Run("accessing non-existent file returns 404", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/f/9999", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected 404, got %d", w.Code) + } + }) +} + +func TestGetDistinctUploadTypes(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + + // Seed some uploads with new custom types + user := model.User{ID: 2222, Username: "test_user_2"} + dbConn.Create(&user) + + customUpload := model.Upload{ + ID: 9001, + UserID: user.ID, + FileName: "custom.txt", + FilePath: "uploads/custom.txt", + FileSize: 10, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Type: "custom_type_xyz", + Status: model.UploadStatusUsed, + } + dbConn.Create(&customUpload) + + gin.SetMode(gin.TestMode) + r := gin.New() + r.GET("/api/v1/admin/uploads/types", GetDistinctUploadTypes) + + req, _ := http.NewRequest("GET", "/api/v1/admin/uploads/types", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d", w.Code) + } + + var resp struct { + ErrorMsg string `json:"error_msg"` + Data []string `json:"data"` + } + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to parse JSON: %v", err) + } + + if resp.ErrorMsg != "" { + t.Fatalf("unexpected error: %s", resp.ErrorMsg) + } + + // Verify that custom_type_xyz and default types are present + hasCustom := false + hasAvatar := false + for _, typeName := range resp.Data { + if typeName == "custom_type_xyz" { + hasCustom = true + } + if typeName == "avatar" { + hasAvatar = true + } + } + + if !hasCustom { + t.Errorf("expected custom_type_xyz to be in types, got: %v", resp.Data) + } + if !hasAvatar { + t.Errorf("expected avatar to be in types, got: %v", resp.Data) + } +} diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go index 9a0cb4a8..42f57bfe 100644 --- a/internal/apps/upload/routers.go +++ b/internal/apps/upload/routers.go @@ -19,6 +19,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strconv" "strings" "time" @@ -577,3 +578,44 @@ func saveUploadRecord(ctx context.Context, upload *model.Upload, storageDriver, } return "" } + +// DefaultUploadTypes 默认内置的文件业务类型 +var DefaultUploadTypes = []string{"avatar", "attachment", "doc", "generic"} + +// GetDistinctUploadTypes 获取所有已存在的文件业务类型及默认内置类型 +// @Summary 获取文件业务类型列表 +// @Description 返回系统中所有已上传文件所拥有的业务类型,并合并默认内置类型(avatar, attachment, doc, generic) +// @Tags admin +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=[]string} "业务类型列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 403 {object} util.ResponseAny "无管理员权限" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/admin/uploads/types [get] +func GetDistinctUploadTypes(c *gin.Context) { + var dbTypes []string + if err := db.DB(c.Request.Context()).Model(&model.Upload{}).Distinct().Pluck("type", &dbTypes).Error; err != nil { + c.JSON(http.StatusInternalServerError, util.Err(err.Error())) + return + } + + // 合并默认内置类型并去重 + typeMap := make(map[string]bool) + for _, t := range DefaultUploadTypes { + typeMap[t] = true + } + for _, t := range dbTypes { + if t != "" { + typeMap[t] = true + } + } + + result := make([]string, 0, len(typeMap)) + for t := range typeMap { + result = append(result, t) + } + sort.Strings(result) + + c.JSON(http.StatusOK, util.OK(result)) +} diff --git a/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql b/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql new file mode 100644 index 00000000..20963f50 --- /dev/null +++ b/internal/db/migrator/goose/postgres/202606110004_add_file_access_whitelist_config.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'file_access_whitelist'; diff --git a/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql b/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql new file mode 100644 index 00000000..20963f50 --- /dev/null +++ b/internal/db/migrator/goose/sqlite/202606110004_add_file_access_whitelist_config.sql @@ -0,0 +1,7 @@ +-- +goose Up +INSERT INTO w_system_configs (key, value, type, visibility, description, created_at, updated_at) +VALUES ('file_access_whitelist', '["avatar"]', 'system', 1, '免登录访问的文件业务类型白名单 (JSON 数组格式)', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (key) DO NOTHING; + +-- +goose Down +DELETE FROM w_system_configs WHERE key = 'file_access_whitelist'; diff --git a/internal/db/migrator/migrator_test.go b/internal/db/migrator/migrator_test.go index 11bcf9d8..a11a55bd 100644 --- a/internal/db/migrator/migrator_test.go +++ b/internal/db/migrator/migrator_test.go @@ -38,8 +38,8 @@ func TestMigrateInitializesSQLiteDatabase(t *testing.T) { if err := sqliteDB.Table("w_system_configs").Count(&systemConfigCount).Error; err != nil { t.Fatalf("Migrate() count w_system_configs error = %v", err) } - if systemConfigCount != 23 { - t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 23) + if systemConfigCount != 24 { + t.Errorf("Migrate() w_system_configs count = %d, want %d", systemConfigCount, 24) } var adminCount int64 diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index c5f9b0a5..f8274696 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -43,6 +43,7 @@ const ( ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证 ConfigKeyMenuDisplayConfig = "menu_display_config" // 目录显示配置 (JSON 字符串) ConfigKeySearchEngineIndexingEnabled = "search_engine_indexing_enabled" // 是否允许搜索引擎检索 + ConfigKeyFileAccessWhitelist = "file_access_whitelist" // 免登录访问的文件业务类型白名单 (JSON 数组格式) ) const ( diff --git a/internal/router/router.go b/internal/router/router.go index 0227944e..1f433792 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -257,6 +257,9 @@ func registerRoutes(r *gin.Engine) { adminRouter.PUT("/users/:id/status", admin_user.UpdateUserStatus) adminRouter.DELETE("/users/:id", admin_user.DeleteUser) + // Uploads + adminRouter.GET("/uploads/types", upload.GetDistinctUploadTypes) + // System Config adminRouter.POST("/system-configs", system_config.CreateSystemConfig) adminRouter.GET("/system-configs", system_config.ListSystemConfigs) diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 7a05e240..e58c1af3 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -37,6 +37,8 @@ func SetupTestEnvironment(t *testing.T) (*gorm.DB, *miniredis.Miniredis, func()) &model.Upload{}, &model.TaskExecution{}, &model.Template{}, + &model.AccessToken{}, + &model.Schedule{}, ) if err != nil { t.Fatalf("failed to auto migrate tables: %v", err) @@ -212,6 +214,12 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { Type: "system", Description: "是否允许搜索引擎检索", }, + { + Key: model.ConfigKeyFileAccessWhitelist, + Value: `["avatar"]`, + Type: "system", + Description: "免登录访问的文件业务类型白名单", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { @@ -232,6 +240,7 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { model.ConfigKeyEmailRegisterVerificationEnabled: {}, model.ConfigKeyMenuDisplayConfig: {}, model.ConfigKeySearchEngineIndexingEnabled: {}, + model.ConfigKeyFileAccessWhitelist: {}, } keys := make([]string, 0, len(publicKeys)) for key := range publicKeys {