mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
文件管理权限控制
This commit is contained in:
@@ -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.<Section>.<Field>`.
|
||||
- `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.<Section>.<Field>`。
|
||||
- `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/<module>/`, 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/<module>/`.
|
||||
- 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/<module>/` 中,使用:
|
||||
- `routers.go` 或 `controllers.go` 作为 HTTP Handler。
|
||||
- `middlewares.go` 作为模块特定的中间件。
|
||||
- `errs.go` 仅包含字符串错误常量。
|
||||
- `constants.go` 包含非错误的业务常量。
|
||||
- 对于管理(Admin)模块,首选 `internal/apps/admin/<module>/`。
|
||||
- 如果 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/<module>/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/<module>/routers.go` 和可选的 `errs.go`。
|
||||
4. 在 `internal/router/router.go` 中注册路由。
|
||||
5. 运行 `make swagger`。
|
||||
|
||||
## Frontend Rules
|
||||
## 前端规则
|
||||
|
||||
Styling:
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
- 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.
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
|
||||
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/<service-name>/
|
||||
@@ -245,26 +200,16 @@ frontend/lib/services/<service-name>/
|
||||
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`.
|
||||
|
||||
<!-- BEGIN:nextjs-agent-rules -->
|
||||
|
||||
# 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.
|
||||
|
||||
<!-- END:nextjs-agent-rules -->
|
||||
切勿删除 `frontend/node_modules`;使用 `pnpm install` 刷新依赖。
|
||||
|
||||
+64
-3
@@ -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"
|
||||
},
|
||||
|
||||
+64
-3
@@ -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"
|
||||
},
|
||||
|
||||
+38
-2
@@ -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:
|
||||
|
||||
@@ -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<string[]>(() => {
|
||||
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 (
|
||||
<div className="space-y-6">
|
||||
{/* 搜索引擎检索设置 */}
|
||||
|
||||
{/* 文件访问白名单设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Globe className="size-4" />
|
||||
<KeyRound className="size-4" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">SEO 与搜索引擎检索</CardTitle>
|
||||
<CardDescription className="text-xs">配置站点是否允许被搜索引擎抓取和检索</CardDescription>
|
||||
<CardTitle className="text-base font-semibold">文件访问权限控制</CardTitle>
|
||||
<CardDescription className="text-xs">配置免登录直接访问的文件业务类型。不在白名单内的文件将要求登录鉴权。</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm">
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<Search className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">允许被搜索引擎检索</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground leading-relaxed pr-2">
|
||||
默认关闭。关闭后系统将自动下发 noindex 指令并限制 robots.txt,禁止搜索引擎爬虫抓取本站页面。
|
||||
</p>
|
||||
<CardContent className="pt-6 space-y-4">
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-muted-foreground">添加免鉴权类型:</span>
|
||||
<Select
|
||||
value=""
|
||||
onValueChange={handleAddType}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending || uploadTypesQuery.isPending}
|
||||
>
|
||||
<SelectTrigger className="w-[200px]" size="sm">
|
||||
<SelectValue placeholder="选择业务类型..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{availableTypes
|
||||
.filter(t => !currentWhitelist.includes(t.value))
|
||||
.map(t => (
|
||||
<SelectItem key={t.value} value={t.value}>
|
||||
{t.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
{availableTypes.filter(t => !currentWhitelist.includes(t.value)).length === 0 && (
|
||||
<div className="text-xs text-muted-foreground p-2 text-center">所有类型已添加</div>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 当前白名单列表 */}
|
||||
<div className="rounded-xl border border-dashed p-4 bg-card hover:bg-muted/10 hover:border-indigo-500/30 transition-all duration-300 shadow-sm space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert className="size-4 text-indigo-500" />
|
||||
<span className="font-medium text-sm text-foreground">当前免鉴权列表</span>
|
||||
</div>
|
||||
|
||||
{currentWhitelist.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{currentWhitelist.map(type => (
|
||||
<Badge
|
||||
key={type}
|
||||
variant="secondary"
|
||||
className="px-2.5 py-1 text-xs gap-1.5 flex items-center bg-indigo-500/10 text-indigo-700 dark:text-indigo-300 dark:bg-indigo-500/20 border border-indigo-500/20"
|
||||
>
|
||||
{availableTypes.find(t => t.value === type)?.label || type}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveType(type)}
|
||||
disabled={updateWhitelistMutation.isPending || systemConfigsQuery.isPending}
|
||||
className="rounded-full outline-hidden hover:bg-indigo-500/20 p-0.5 text-indigo-600 dark:text-indigo-400 cursor-pointer disabled:cursor-not-allowed"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
白名单已空,所有类型文件的访问都将需要登录。
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<Switch
|
||||
checked={indexingEnabled}
|
||||
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfigMutation.mutate({ key: "search_engine_indexing_enabled", value: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-8">
|
||||
{/* 通用设置 */}
|
||||
<Card className="border border-dashed shadow-sm">
|
||||
<CardHeader className="border-b border-dashed pb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="p-1.5 rounded-lg bg-indigo-500/10 text-indigo-500">
|
||||
<Server className="size-4" />
|
||||
<Card className="border border-zinc-200 dark:border-zinc-800 shadow-md bg-gradient-to-b from-card to-zinc-50/30 dark:to-zinc-950/20 overflow-hidden">
|
||||
<CardHeader className="border-b border-zinc-100 dark:border-zinc-800 pb-5 bg-zinc-50/50 dark:bg-zinc-900/30">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="p-2.5 rounded-xl bg-indigo-500/10 text-indigo-500 ring-4 ring-indigo-500/5">
|
||||
<Server className="size-5" />
|
||||
</div>
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">通用设置</CardTitle>
|
||||
<CardDescription className="text-xs">配置系统的全局通用参数</CardDescription>
|
||||
<CardTitle className="text-lg font-bold tracking-tight">通用设置</CardTitle>
|
||||
<CardDescription className="text-xs text-muted-foreground">配置系统的全局网络通信访问限制与搜索引擎的公开检索收录参数</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-6">
|
||||
<form onSubmit={handleSystemSave} className="space-y-6">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="server_address" className="text-xs font-semibold">服务器地址</Label>
|
||||
<Input
|
||||
id="server_address"
|
||||
type="text"
|
||||
value={serverAddress}
|
||||
onChange={(e) => setServerAddress(e.target.value)}
|
||||
placeholder="例如: https://example.com"
|
||||
className="bg-card border-dashed text-xs"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground leading-normal">
|
||||
这里可以编辑更改服务器地址。默认不设定,允许从任意源(*)访问 API,此时存在跨域安全风险;如果手动设置服务器地址,CORS 允许源将更新为该地址,消除跨域安全隐患。
|
||||
</p>
|
||||
<CardContent className="p-6">
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
|
||||
{/* 跨域源与服务器地址 */}
|
||||
<form onSubmit={handleSystemSave} className="group relative flex flex-col justify-between space-y-5 rounded-2xl border border-zinc-200/80 dark:border-zinc-800/80 p-6 bg-card hover:shadow-lg hover:border-indigo-500/30 dark:hover:border-indigo-500/30 transition-all duration-300">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2.5 text-indigo-500">
|
||||
<Globe className="size-5" />
|
||||
<span className="font-semibold text-sm text-foreground">访问域名与跨域来源限制</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleDetectAddress}
|
||||
className="h-7 px-2.5 text-[10px] gap-1 font-medium hover:bg-indigo-500/10 hover:text-indigo-500 hover:border-indigo-500/30 transition-all duration-200 shadow-xs"
|
||||
>
|
||||
<Sparkles className="size-3 text-indigo-500" />
|
||||
使用当前域名
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label htmlFor="server_address" className="text-xs font-semibold text-muted-foreground">服务器访问地址 (Server Address)</Label>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="server_address"
|
||||
type="text"
|
||||
value={serverAddress}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-start gap-1.5 mt-2 bg-zinc-50 dark:bg-zinc-900/30 p-2.5 rounded-lg border border-zinc-100 dark:border-zinc-800/50">
|
||||
<Info className="size-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
配置 API 的对外服务访问域名。留空则允许任意源访问(CORS 将开放 `*`,有安全风险)。若配置具体域名,将激活同源及 CORS 白名单验证保护。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4 border-t border-zinc-100 dark:border-zinc-800/50">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
className="h-8.5 px-4 text-xs font-medium bg-indigo-600 hover:bg-indigo-500 text-white shadow-md hover:shadow-indigo-500/10 active:scale-[0.98] transition-all"
|
||||
disabled={saveSystemMutation.isPending}
|
||||
>
|
||||
{saveSystemMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存访问配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* SEO 搜索引擎抓取 */}
|
||||
<div className={`group relative flex flex-col justify-between space-y-5 rounded-2xl border p-6 bg-card hover:shadow-lg transition-all duration-500 ${
|
||||
indexingEnabled
|
||||
? "border-emerald-500/20 dark:border-emerald-500/30 hover:border-emerald-500/40 bg-gradient-to-b from-card to-emerald-500/[0.02] dark:to-emerald-500/[0.04]"
|
||||
: "border-zinc-200 dark:border-zinc-800 hover:border-zinc-300 dark:hover:border-zinc-700 bg-gradient-to-b from-card to-zinc-500/[0.01]"
|
||||
}`}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`flex items-center gap-2.5 transition-colors duration-300 ${indexingEnabled ? "text-emerald-500" : "text-zinc-500"}`}>
|
||||
<Search className="size-5" />
|
||||
<span className="font-semibold text-sm text-foreground">搜索引擎抓取检索 (SEO)</span>
|
||||
</div>
|
||||
<Badge
|
||||
variant={indexingEnabled ? "default" : "secondary"}
|
||||
className={`text-[10px] font-semibold tracking-wide px-2.5 py-0.5 shadow-xs transition-all duration-300 ${
|
||||
indexingEnabled
|
||||
? "bg-emerald-500/15 text-emerald-600 hover:bg-emerald-500/20 dark:bg-emerald-500/20 dark:text-emerald-400 border border-emerald-500/20"
|
||||
: "bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400 border border-zinc-200 dark:border-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span className={`inline-block size-1.5 rounded-full mr-1.5 shrink-0 ${indexingEnabled ? "bg-emerald-500 animate-pulse" : "bg-zinc-400"}`} />
|
||||
{indexingEnabled ? "已启用索引" : "已屏蔽检索"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<span className="text-xs font-semibold text-muted-foreground block">站点检索可见性开关</span>
|
||||
<div className="flex items-start gap-1.5 bg-zinc-50 dark:bg-zinc-900/30 p-2.5 rounded-lg border border-zinc-100 dark:border-zinc-800/50">
|
||||
<Info className="size-3.5 text-muted-foreground shrink-0 mt-0.5" />
|
||||
<p className="text-[10px] text-muted-foreground leading-relaxed">
|
||||
控制本系统是否向主流搜索引擎(如 Google、Baidu、Bing)开放公开索引。关闭时,系统响应的 HTML 头部会自动注入 meta robots 标签,防止爬虫收录。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-4 border-t border-zinc-100 dark:border-zinc-800/50 h-12">
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xs text-foreground font-semibold">
|
||||
{indexingEnabled ? "允许爬虫访问与收录" : "全面屏蔽外部搜索"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{indexingEnabled ? "爬虫可自由搜集并抓取网站内容" : "已拦截所有网络爬虫的收录请求"}
|
||||
</span>
|
||||
</div>
|
||||
<Switch
|
||||
checked={indexingEnabled}
|
||||
disabled={updateConfigMutation.isPending || systemConfigsQuery.isPending}
|
||||
onCheckedChange={(checked) =>
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end pt-4 border-t border-dashed">
|
||||
<Button
|
||||
type="submit"
|
||||
size="sm"
|
||||
disabled={saveSystemMutation.isPending}
|
||||
>
|
||||
{saveSystemMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
"保存配置"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -370,3 +488,4 @@ export function SystemTab({ configs, systemConfigsQuery }: SystemTabProps) {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string[]> {
|
||||
return this.get<string[]>('/uploads/types');
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
@@ -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
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user