diff --git a/Agents.md b/Agents.md index 4c83892a..a4c0549e 100644 --- a/Agents.md +++ b/Agents.md @@ -36,6 +36,8 @@ ## 二、顶层目录结构 +以下是项目的顶层目录结构及其职责, 如果有新增目录或文件,请务必在此处同步更新: + ``` Refreshing/ # 项目根目录(模块名: github.com/linux-do/credit) ├── main.go # 程序入口,调用 internal/cmd @@ -57,6 +59,8 @@ Refreshing/ # 项目根目录(模块名: github.com/lin ## 三、后端 `internal/` 目录结构 +以下是 `internal/` 目录的结构及其职责, 如果有新增目录或文件,请务必在此处同步更新: + ``` internal/ ├── cmd/ # CLI 命令入口(Cobra) @@ -185,6 +189,8 @@ apps/admin/ ## 五、前端 `frontend/` 目录结构 +以下是前端 `frontend/` 目录的结构, 如果需要调整请在此处同步更改: + ``` frontend/ ├── app/ # Next.js App Router 页面目录 @@ -197,7 +203,7 @@ frontend/ │ ├── components/ # 可复用 React 组件 │ ├── ui/ # shadcn/ui 基础组件(Button/Input/Dialog 等) -│ ├── common/ # 通用业务组件(跨页面复用) +│ ├── common/ # 通用业务组件(跨页面复用),详见下方说明 │ ├── layout/ # 布局组件(Header / Sidebar / Footer) │ ├── auth/ # 认证相关组件 │ ├── home/ # 首页专属组件 @@ -219,6 +225,52 @@ frontend/ --- +## 5.1 前端 `components/common/` 通用业务组件详解 + +`common/` 目录存放跨页面复用的业务组件,按功能域分为五个子目录。以下是每个文件的职责说明: + +``` +components/common/ +├── admin/ # 管理员后台组件 +│ ├── tasks.tsx # TaskManager — 异步任务调度管理页面,展示所有可用任务类型, +│ │ # 支持通过弹窗配置参数后立即下发任务到后台队列执行 +│ ├── system.tsx # SystemConfigs — 系统 KV 配置管理页面,以表格展示系统/业务两类 +│ │ # 配置项,支持在线编辑(布尔类型自动渲染为 Switch)并保存/删除 +│ └── users.tsx # UsersManager — 用户管理页面,提供分页、搜索、筛选的用户列表表格, +│ # 支持在侧边抽屉查看用户详情,以及启用/禁用(封禁/解封)切换 +│ +├── docs/ # 文档页面组件,包括法律文档(隐私政策/服务条款)和接口文档 +│ +├── general/ # 通用框架组件 +│ ├── manage-pannel.tsx # ManagePage(泛型)— 通用管理页面框架,封装"列表 + 详情面板"布局, +│ │ # 包含数据加载/错误/空状态处理、表格渲染、选中/悬停交互、 +│ │ # 编辑/保存/删除逻辑;ManageDetailPanel 为带保存按钮的详情面板; +│ │ # ManageTable 为配置驱动型表格组件 +│ └── password-dialog.tsx # PasswordDialog — 密码确认弹窗,用于敏感操作前的二次身份验证, +│ # 包含 6 位 OTP 输入框,支持 Enter 快捷确认,带加载状态显示 +│ +├── home/ # 首页组件 +│ └── home-main.tsx # HomeMain — 系统首页主内容,展示当前用户的快捷导航卡片 +│ # (个人资料、开发接口文档、使用文档),管理员额外显示后台管理入口 +│ +└── settings/ # 设置页面组件 + ├── access-token.tsx # AccessTokenMain — 个人访问令牌管理页面,展示用户 API 密钥列表, + │ # 支持创建(仅展示一次明文)、轮换、撤销/删除令牌 + ├── appearance.tsx # AppearanceMain — 外观设置页面,分为主题模式选择 + │ # (明亮/黑暗/自动)和界面配色方案(可视化色卡网格切换) + ├── auth-source-modal.tsx # AuthSourceModal — OIDC 认证源新增/编辑弹窗,包含标识符、 + │ # Client ID/Secret、Discovery URL、Scopes、图标等表单字段 + ├── notifications.tsx # NotificationsMain — 通知设置页面,控制顶部导航栏 + │ # 是否显示通知铃铛图标,通过 Context 持久化偏好 + ├── profile.tsx # ProfileMain — 个人资料页面,展示用户基本信息,提供第三方 + │ # 账号绑定管理(查看已绑定 OIDC 账号、解除绑定、绑定新认证源) + └── security.tsx # SecurityMain — 系统安全设置页面(管理员专属),包含系统登录与 + # 注册控制(密码登录/注册/密码注册/OIDC 登录四个开关)、 + # 认证源管理(新增、编辑、启用/禁用、删除 OIDC 认证源) +``` + +--- + ## 六、开发规范 ### 6.1 命名规范 @@ -258,24 +310,6 @@ func ListUsers(c *gin.Context) { - 失败:`util.Err(msg)` + 对应 HTTP 状态码 - 通过 `response.RespondSuccess / RespondFailure` 也可(两套工具共存) -### 6.3 Swagger 注释规范 - -所有对外 Handler 必须添加 Swaggo 注释: - -```go -// ListUsers 获取用户列表 -// @Summary 获取用户列表 -// @Description 分页返回用户列表,支持按用户 ID 和用户名筛选,需要管理员权限 -// @Tags admin -// @Produce json -// @Param request query listUsersRequest true "查询参数" -// @Success 200 {object} util.ResponseAny -// @Router /api/v1/admin/users [get] -func ListUsers(c *gin.Context) { ... } -``` - -生成文档:`make swagger`(执行 `scripts/swagger.sh`) - ### 6.4 错误处理规范 - **模块内错误消息**:定义在本模块 `errs.go` 中,使用 `const` 字符串。 @@ -317,6 +351,46 @@ func ListUsers(c *gin.Context) { ... } **队列优先级**(从高到低):`webhook` > `whitelist_only` > `default` +### 6.9 前端组件样式规范 + +**基础组件必须遵循系统的色彩主题系统。** 所有基于 shadcn/ui 的基础组件(Button、Dialog、Input 等)应使用组件内置的 `variant` 属性来控制样式,禁止通过 `className` 手写颜色或背景等样式。 + +**错误示例(禁止)**: + +```tsx +// ❌ 禁止通过 className 手写颜色、背景、阴影等样式 + +``` + +**正确示例**: + +```tsx +// ✅ 使用 variant 属性,让组件遵循系统主题 + +``` + +> **原则**:组件的视觉表现由 shadcn/ui 的 variant 系统和全局 CSS 变量统一控制,保持应用内所有页面风格一致。如现有 variant 无法满足需求,应扩展 shadcn/ui 组件的 variant 定义,而非在业务代码中硬编码颜色值。 + +### 6.10 严格禁止事项 + +| 禁止行为 | 说明 | +|----------|------| +| **禁止删除 `node_modules` 目录** | `node_modules` 为前端依赖安装目录,删除会导致项目无法运行。如需重新安装依赖,使用 `pnpm install` 覆盖更新即可,严禁执行 `rm -rf node_modules`。 | + --- ## 七、新增功能开发流程 @@ -333,6 +407,31 @@ func ListUsers(c *gin.Context) { ... } 5. 执行 make swagger 更新文档 ``` +**Handler 文件拆分规则**: + +逻辑简单的 CRUD 可以全部放在 `routers.go` 中。但当文件代码行数增长时,必须按以下规则拆分: + +| 条件 | 拆分方式 | +|---------------------------|----------| +| 文件超过 **600 行** | 必须拆分 | +| 包含复杂业务逻辑(如外部调用、多步校验、事务处理) | 将业务逻辑拆到 `logic.go` 或 `logics.go` | +| 同一模块有多个独立功能域 | 按功能域拆分多个文件,如 `user_routers.go`、`role_routers.go` | + +拆分后的模块文件结构示例: + +``` +apps/admin// +├── routers.go # 路由注册入口 + 简单 Handler(参数绑定 → 调用逻辑 → 响应) +├── logics.go # 复杂业务逻辑(外部调用、事务、多步处理) +├── errs.go # 错误常量 +└── constants.go # 业务常量(按需) +``` + +**职责边界**: + +- `routers.go` 只做三件事:参数绑定、调用 logic 函数、返回响应。不包含任何业务判断逻辑。 +- `logics.go` 负责所有业务逻辑,接收已校验的参数,返回处理结果和错误。函数以 `PascalCase` 导出,供 `routers.go` 调用。 + 以新增 **异步任务** 为例: ``` @@ -345,20 +444,3 @@ func ListUsers(c *gin.Context) { ... } ``` --- - -## 八、关键依赖版本 - -| 依赖 | 版本 | -|------|------| -| Go | 1.25+ | -| Gin | v1.11.0 | -| GORM | v1.31.1 | -| go-redis | v9.16.0 | -| Asynq | v0.25.1 | -| Cobra | v1.10.1 | -| Viper | v1.21.0 | -| Zap | v1.27.0 | -| Snowflake | v0.3.0 | -| OpenTelemetry | v1.36.0 | -| Next.js | (见 frontend/package.json) | -| pnpm | (见 frontend/pnpm-workspace.yaml) | diff --git a/README.md b/README.md index ec19b726..c3064feb 100644 --- a/README.md +++ b/README.md @@ -1,126 +1,134 @@ -# LINUX DO Credit +# Refreshing -🚀 Linux Do Community Credit Service Platform +🚀 A modern, production-ready full-stack boilerplate for building scalable web applications [中文](./README_zh.md) [![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) +[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) -[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) -[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) -[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) -[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) -[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) - -[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) -[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) -[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) -[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) -[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) - ## 📖 Introduction -LINUX DO Credit is a credit service platform built for the Linux Do community, aimed at providing a series of credit-related services and offering a foundational framework for credit circulation for community developers. +**Refreshing** is a generic, production-ready full-stack boilerplate built with **Go (Gin + GORM)** on the backend and **Next.js (App Router + Shadcn UI)** on the frontend. It ships with everything you need to bootstrap a modern SaaS, internal tool, or developer platform — without the boilerplate headaches. + +The project was designed from the ground up to be **framework-first and business-agnostic**: plug in your own domain logic while reusing the battle-tested infrastructure that comes out of the box. ### ✨ Key Features -- 🔐 **OAuth2 Authentication** - Integrated with Linux Do community account system -- 🛡️ **Risk Control** - Comprehensive trust level and risk assessment system -- 📊 **Real-time Monitoring** - Detailed distribution statistics and user behavior analysis -- 🎨 **Modern Interface** - Responsive design based on Next.js 16 and React 19 -- ⚡ **High Performance** - Go Backend + Redis Cache + PostgreSQL Database +- 🔐 **Multi-auth System** — Local password login/registration + pluggable OIDC/OAuth2 providers (supports multiple auth sources simultaneously) +- 🗝️ **Personal Access Tokens** — API key management for programmatic access; supports `Authorization: Bearer` and `X-Access-Token` headers +- 👤 **User Management** — Admin panel for listing, searching, filtering, enabling/disabling user accounts +- ⚙️ **Dynamic System Config** — Key-value system configuration management with live reload, controllable from the admin UI +- 📋 **Async Task Queue** — Background job processing with [Asynq](https://github.com/hibiken/asynq) (Redis-backed), including a scheduling dashboard +- 📁 **S3 File Storage** — Unified file upload/download via S3-compatible APIs with local disk cache +- 📊 **Observability** — Structured logging (Zap) + distributed tracing (OpenTelemetry) +- 🎨 **Modern UI** — Responsive, dark-mode-ready design system built with Tailwind CSS 4 and Shadcn UI +- 📖 **Built-in Documentation** — Integrated docs portal with usage guides, API reference, privacy policy, and terms of service ## 🏗️ Architecture Overview ``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Frontend │ │ Backend │ │ Database │ -│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ -│ │ │ │ │ │ -│ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ -│ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ -│ • Tailwind CSS │ │ • Session Store │ │ │ -│ • Shadcn UI │ │ • OpenTelemetry │ │ │ -│ │ │ • Swagger API │ │ │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ +┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐ +│ Frontend │ │ Backend │ │ Database │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin HTTP Framework │ │ • PostgreSQL │ +│ • TypeScript │ │ • GORM ORM │ │ • Redis Cache │ +│ • Tailwind 4 │ │ • Multi-provider Auth │ │ │ +│ • Shadcn UI │ │ • AccessToken Middleware │ │ │ +│ │ │ • Asynq Task Queue │ │ │ +│ │ │ • OpenTelemetry Tracing │ │ │ +│ │ │ • Swagger API Docs │ │ │ +└─────────────────┘ └─────────────────────────────┘ └─────────────────┘ + │ + ┌──────────┴──────────┐ + │ Multi-Process CLI │ + │ (Cobra + Viper) │ + │ • api (HTTP) │ + │ • worker (Queue) │ + │ • scheduler(Cron) │ + └─────────────────────┘ ``` ## 🛠️ Tech Stack ### Backend -- **[Go 1.25.5](https://go.dev/doc)** - Primary development language -- **[GIN](https://github.com/gin-gonic/gin)** - Web Framework -- **[GORM](https://github.com/go-gorm/gorm)** - ORM Framework -- **[Redis](https://github.com/redis/redis)** - Cache and session store -- **[PostgreSQL](https://www.postgresql.org)** - Primary Database -- **[OpenTelemetry](https://opentelemetry.io)** - Observability -- **[Swagger](https://github.com/swaggo/swag)** - API Documentation +- **[Go 1.25+](https://go.dev/doc)** — Primary language +- **[Gin](https://github.com/gin-gonic/gin)** — HTTP web framework +- **[GORM](https://github.com/go-gorm/gorm)** — ORM with PostgreSQL & ClickHouse support +- **[Redis](https://github.com/redis/redis)** — Cache, session store, and task queue backend +- **[Asynq](https://github.com/hibiken/asynq)** — Distributed task queue (Redis-backed) +- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI entrypoint and configuration management +- **[OpenTelemetry](https://opentelemetry.io)** — Distributed tracing and observability +- **[Zap](https://github.com/uber-go/zap)** — Structured, high-performance logging +- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — Auto-generated API documentation +- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3-compatible file storage +- **[Snowflake](https://github.com/bwmarrin/snowflake)** — Distributed ID generation ### Frontend -- **[Next.js 16](https://github.com/vercel/next.js)** - React Framework -- **[React 19](https://github.com/facebook/react)** - UI Library -- **[TypeScript](https://github.com/microsoft/TypeScript)** - Type Safety -- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - Styling Framework -- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - Component Library -- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - Icon Library +- **[Next.js 16](https://github.com/vercel/next.js)** — React framework with App Router +- **[React 19](https://github.com/facebook/react)** — UI library +- **[TypeScript](https://github.com/microsoft/TypeScript)** — Type safety +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — Utility-first styling +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — Accessible, composable component library +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — Icon library ## 📋 Requirements -- **Go** >= 1.25.5 +- **Go** >= 1.25 - **Node.js** >= 18.0 -- **PostgreSQL** >= 18 +- **PostgreSQL** >= 14 - **Redis** >= 6.0 -- **pnpm** >= 8.0 (Recommended) +- **pnpm** >= 8.0 (recommended) ## 🚀 Quick Start -### 1. Clone the Project +### 1. Clone the Repository ```bash -git clone https://github.com/linux-do/credit.git -cd credit +git clone https://github.com/linux-do/credit.git refreshing +cd refreshing ``` ### 2. Configure Environment -Copy the configuration file and edit it: - ```bash cp config.example.yaml config.yaml ``` -Edit `config.yaml` to configure database connections, Redis, OAuth2, etc. +Edit `config.yaml` to configure your database, Redis, and at least one auth source (OIDC or password-based). ### 3. Initialize Database ```bash -# Create database -createdb -h -p 5432 -U postgres linux_do_credit +# Create the database +createdb -h -p 5432 -U postgres refreshing -# If you need to specify encoding, use: -# psql -h -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" - -# Run migrations (automatically executed when starting the backend) +# Database schema is auto-migrated on first startup ``` -### 4. Start Backend +### 4. Start the Backend ```bash # Install Go dependencies go mod tidy -# Generate API documentation +# Generate Swagger API documentation make swagger -# Start backend service +# Start the HTTP API server go run main.go api ``` -### 5. Start Frontend +> The backend also supports separate `scheduler` and `worker` processes for async task processing: +> ```bash +> go run main.go scheduler # Cron job scheduler +> go run main.go worker # Asynq task worker +> ``` + +### 5. Start the Frontend ```bash cd frontend @@ -128,40 +136,34 @@ cd frontend # Install dependencies pnpm install -# Start development server +# Start dev server (Turbopack) pnpm dev ``` -### 6. Access Application +### 6. Access the Application -- **Frontend Interface**: http://localhost:3000 -- **API Documentation**: http://localhost:8000/swagger/index.html -- **Health Check**: http://localhost:8000/api/health +| Service | URL | +|---------|-----| +| Frontend | http://localhost:3000 | +| Swagger API Docs | http://localhost:8000/swagger/index.html | +| Health Check | http://localhost:8000/api/health | ## ⚙️ Configuration -### Main Configuration Options +Key configuration options (see `config.example.yaml` for the full reference): | Option | Description | Example | |--------|-------------|---------| -| `app.addr` | Backend service listening address | `:8000` | -| `oauth2.client_id` | OAuth2 Client ID | `your_client_id` | -| `database.host` | PostgreSQL database host | `127.0.0.1` | -| `database.port` | PostgreSQL database port | `5432` | -| `database.username` | PostgreSQL database username | `postgres` | -| `database.password` | PostgreSQL database password | `password` | -| `database.database` | PostgreSQL database name | `linux_do_credit` | -| `database.ssl_mode` | PostgreSQL SSL mode | `disable` | -| `database.application_name` | PostgreSQL application name | `credit-server` | -| `database.search_path` | PostgreSQL search path | `public` | -| `database.default_query_exec_mode` | SQL cache mode | `cache_statement` | -| `redis.host` | Redis server address | `127.0.0.1` | - -For detailed configuration instructions, please refer to the `config.example.yaml` file. +| `app.addr` | Backend listen address | `:8000` | +| `database.host` | PostgreSQL host | `127.0.0.1` | +| `database.database` | Database name | `refreshing` | +| `redis.host` | Redis host | `127.0.0.1` | +| `storage.endpoint` | S3-compatible endpoint | `s3.amazonaws.com` | +| `oauth2.client_id` | Default OIDC client ID | `your_client_id` | ## 🔧 Development Guide -### Backend Development +### Backend ```bash # Run API server @@ -170,115 +172,135 @@ go run main.go api # Run task scheduler go run main.go scheduler -# Run worker queue +# Run async worker go run main.go worker -# Generate Swagger documentation +# Regenerate Swagger docs (required after controller changes) make swagger -# Format and check code +# Format & vet code make tidy ``` -### Frontend Development +### Frontend ```bash cd frontend -# Development mode (using Turbopack) +# Development mode (Turbopack) pnpm dev -# Build production version +# Production build pnpm build -# Start production service +# Start production server pnpm start -# Lint and format code +# Lint & format pnpm lint pnpm format ``` +## 📁 Project Structure + +``` +Refreshing/ +├── main.go # Entry point (delegates to internal/cmd) +├── config.example.yaml # Configuration template +├── Makefile # Common commands (swagger, tidy, license) +├── Dockerfile # Container image build +├── docs/ # Swagger auto-generated docs +├── frontend/ # Next.js frontend application +│ ├── app/ # App Router pages +│ ├── components/ # React components (ui, common, layout) +│ ├── lib/services/ # API service layer +│ └── types/ # TypeScript type definitions +└── internal/ # Go backend (private) + ├── cmd/ # CLI commands (api, scheduler, worker) + ├── apps/ # Business modules (oauth, user, admin, upload) + ├── model/ # GORM entities and business methods + ├── router/ # HTTP route registration + ├── task/ # Async task definitions and workers + ├── db/ # Database and Redis initialization + ├── storage/ # S3 file storage abstraction + └── common/ # Shared utilities and response helpers +``` + ## 📚 API Documentation -API documentation is automatically generated by Swagger and can be accessed after starting the backend service: +Swagger API documentation is auto-generated and available once the backend is running: ``` http://localhost:8000/swagger/index.html ``` +The built-in frontend docs portal at `/docs` includes: +- **Usage Guide** — Step-by-step walkthrough for getting started +- **API Reference** — Detailed interface documentation +- **Privacy Policy** — Template privacy policy (customize as needed) +- **Terms of Service** — Template terms of service + ## 🧪 Testing ```bash -# Backend testing +# Backend tests go test ./... -# Frontend testing -cd frontend -pnpm test +# Frontend lint +cd frontend && pnpm lint ``` ## 🚀 Deployment -### Docker Deployment +### Docker ```bash # Build image -docker build -t linux-do-credit . +docker build -t refreshing . -# Run container -docker run -d -p 8000:8000 linux-do-credit +# Run (pass your config as a volume mount) +docker run -d -p 8000:8000 \ + -v $(pwd)/config.yaml:/app/config.yaml \ + refreshing api ``` -### Production Environment Deployment +### Production -1. Build frontend resources: +1. Build the frontend: ```bash cd frontend && pnpm build ``` -2. Compile backend program: +2. Compile the backend: ```bash - go build -o credit main.go + go build -o refreshing main.go ``` -3. Configure `config.yaml` for production +3. Configure `config.yaml` for production. -4. Start service: +4. Start services: ```bash - ./credit api + ./refreshing api # HTTP API + ./refreshing scheduler # Cron scheduler (optional) + ./refreshing worker # Task worker (optional) ``` -## 🤝 Contribution Guidelines +## 🤝 Contributing -We welcome community contributions! Please read the following before submitting code: +We welcome contributions! Please read the following before submitting code: -- [Contribution Guidelines](CONTRIBUTING.md) +- [Contributing Guidelines](CONTRIBUTING.md) - [Code of Conduct](CODE_OF_CONDUCT.md) - [Contributor License Agreement](CLA.md) -### Submission Process +### Workflow -1. Fork this repository +1. Fork the repository 2. Create a feature branch (`git checkout -b feature/your-feature`) -3. Commit changes (`git commit -am 'Add your feature'`) -4. Push to branch (`git push origin feature/your-feature`) -5. Create Pull Request +3. Commit your changes (`git commit -am 'Add your feature'`) +4. Push to the branch (`git push origin feature/your-feature`) +5. Open a Pull Request ## 📄 License -This project is open source under the [Apache2.0 License](LICENSE). - -## 🔗 Related Links - -- [Linux Do Community](https://linux.do) -- [Issue Reporting](https://github.com/linux-do/credit/issues) -- [Feature Request](https://github.com/linux-do/credit/issues/new?template=feature_request.md) - -## ❤️ Acknowledgements - -Thanks to all developers who contributed to this project and the support of the Linux Do Community! - -## 📈 Star History - -[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) +This project is licensed under the [Apache 2.0 License](LICENSE). diff --git a/README_zh.md b/README_zh.md index 58a0ed9e..1335b828 100644 --- a/README_zh.md +++ b/README_zh.md @@ -1,110 +1,112 @@ -# LINUX DO Credit +# Refreshing -🚀 Linux Do 社区 Credit 积分服务平台 +🚀 现代化、生产就绪的全栈应用脚手架 [English](./README.md) [![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) -[![Go Version](https://img.shields.io/badge/Go-1.25.5-blue.svg)](https://golang.org/) +[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/) [![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) [![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) -[![GitHub release](https://img.shields.io/github/v/release/linux-do/credit?include_prereleases)](https://github.com/linux-do/credit/releases) -[![GitHub stars](https://img.shields.io/github/stars/linux-do/credit)](https://github.com/linux-do/credit/stargazers) -[![GitHub forks](https://img.shields.io/github/forks/linux-do/credit)](https://github.com/linux-do/credit/network) -[![GitHub issues](https://img.shields.io/github/issues/linux-do/credit)](https://github.com/linux-do/credit/issues) -[![GitHub pull requests](https://img.shields.io/github/issues-pr/linux-do/credit)](https://github.com/linux-do/credit/pulls) -[![GitHub contributors](https://img.shields.io/github/contributors/linux-do/credit)](https://github.com/linux-do/credit/graphs/contributors) - -[![Backend Build](https://github.com/linux-do/credit/actions/workflows/build_backend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_backend.yml) -[![Frontend Build](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_frontend.yml) -[![Docker Build](https://github.com/linux-do/credit/actions/workflows/build_image.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/build_image.yml) -[![CodeQL](https://github.com/linux-do/credit/actions/workflows/codeql.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/codeql.yml) -[![ESLint](https://github.com/linux-do/credit/actions/workflows/eslint.yml/badge.svg)](https://github.com/linux-do/credit/actions/workflows/eslint.yml) - ## 📖 项目简介 -LINUX DO Credit 是一个为 Linux Do 社区打造的积分服务平台,旨在提供一系列积分相关服务,为社区开发者提供积分流转基础框架。 +**Refreshing** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。 + +项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。 ### ✨ 主要特性 -- 🔐 **OAuth2 认证** - 集成 Linux Do 社区账号系统 -- 🛡️ **风险控制** - 完善的信任等级和风险评估系统 -- 📊 **实时监控** - 详细的分发统计和用户行为分析 -- 🎨 **现代化界面** - 基于 Next.js 16 和 React 19 的响应式设计 -- ⚡ **高性能** - Go 后端 + Redis 缓存 + PostgreSQL 数据库 +- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源) +- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头 +- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能 +- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作 +- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板 +- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存 +- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry) +- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统 +- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款 ## 🏗️ 架构概览 ``` -┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ -│ Frontend │ │ Backend │ │ Database │ -│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ -│ │ │ │ │ │ -│ • React 19 │ │ • Gin Framework │ │ • PostgreSQL │ -│ • TypeScript │ │ • OAuth2 │ │ • Redis Cache │ -│ • Tailwind CSS │ │ • Session Store │ │ │ -│ • Shadcn UI │ │ • OpenTelemetry │ │ │ -│ │ │ • Swagger API │ │ │ -└─────────────────┘ └─────────────────┘ └─────────────────┘ +┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐ +│ 前端 │ │ 后端 │ │ 数据库 │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │ +│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │ +│ • Tailwind 4 │ │ • 多认证源适配 │ │ │ +│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │ +│ │ │ • Asynq 任务队列 │ │ │ +│ │ │ • OpenTelemetry 链路追踪 │ │ │ +│ │ │ • Swagger 接口文档 │ │ │ +└─────────────────┘ └─────────────────────────────┘ └─────────────────┘ + │ + ┌──────────┴──────────┐ + │ 多进程 CLI 入口 │ + │ (Cobra + Viper) │ + │ • api (HTTP) │ + │ • worker (队列) │ + │ • scheduler(定时) │ + └─────────────────────┘ ``` ## 🛠️ 技术栈 ### 后端 -- **[Go 1.25.5](https://go.dev/doc)** - 主要开发语言 -- **[GIN](https://github.com/gin-gonic/gin)** - Web 框架 -- **[GORM](https://github.com/go-gorm/gorm)** - ORM 框架 -- **[Redis](https://github.com/redis/redis)** - 缓存和会话存储 -- **[PostgreSQL](https://www.postgresql.org)** - 主数据库 -- **[OpenTelemetry](https://opentelemetry.io)** - 可观测性 -- **[Swagger](https://github.com/swaggo/swag)** - API 文档 +- **[Go 1.25+](https://go.dev/doc)** — 主语言 +- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架 +- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse +- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端 +- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动) +- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理 +- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性 +- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志 +- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档 +- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储 +- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成 ### 前端 -- **[Next.js 16](https://github.com/vercel/next.js)** - React 框架 -- **[React 19](https://github.com/facebook/react)** - UI 库 -- **[TypeScript](https://github.com/microsoft/TypeScript)** - 类型安全 -- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** - 样式框架 -- **[Shadcn UI](https://github.com/shadcn-ui/ui)** - 组件库 -- **[Lucide Icons](https://github.com/lucide-icons/lucide)** - 图标库 +- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router) +- **[React 19](https://github.com/facebook/react)** — UI 库 +- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全 +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架 +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库 +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库 ## 📋 环境要求 -- **Go** >= 1.25.5 +- **Go** >= 1.25 - **Node.js** >= 18.0 -- **PostgreSQL** >= 18 +- **PostgreSQL** >= 14 - **Redis** >= 6.0 -- **pnpm** >= 8.0 (推荐) +- **pnpm** >= 8.0(推荐) ## 🚀 快速开始 -### 1. 克隆项目 +### 1. 克隆仓库 ```bash -git clone https://github.com/linux-do/credit.git -cd credit +git clone https://github.com/linux-do/credit.git refreshing +cd refreshing ``` ### 2. 配置环境 -复制配置文件并编辑: - ```bash cp config.example.yaml config.yaml ``` -编辑 `config.yaml` 文件,配置数据库连接、Redis、OAuth2 等信息。 +编辑 `config.yaml`,配置数据库、Redis,以及至少一个认证源(OIDC 或密码登录)。 ### 3. 初始化数据库 ```bash # 创建数据库 -createdb -h <主机> -p 5432 -U postgres linux_do_credit +createdb -h <主机> -p 5432 -U postgres refreshing -# 如果需要指定字符集,可使用 -# psql -h <主机> -p 5432 -U postgres -c "CREATE DATABASE linux_do_credit WITH ENCODING 'UTF8' LC_COLLATE='zh_CN.UTF-8' LC_CTYPE='zh_CN.UTF-8' TEMPLATE template0;" - -# 运行迁移(启动后端时会自动执行) +# 数据库表结构在首次启动时自动迁移,无需手动执行 ``` ### 4. 启动后端 @@ -113,13 +115,19 @@ createdb -h <主机> -p 5432 -U postgres linux_do_credit # 安装 Go 依赖 go mod tidy -# 生成 API 文档 +# 生成 Swagger 接口文档 make swagger -# 启动后端服务 +# 启动 HTTP API 服务器 go run main.go api ``` +> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务: +> ```bash +> go run main.go scheduler # 定时任务调度器 +> go run main.go worker # Asynq 任务处理工作进程 +> ``` + ### 5. 启动前端 ```bash @@ -128,109 +136,135 @@ cd frontend # 安装依赖 pnpm install -# 启动开发服务器 +# 启动开发服务器(Turbopack) pnpm dev ``` ### 6. 访问应用 -- **前端界面**: http://localhost:3000 -- **API 文档**: http://localhost:8000/swagger/index.html -- **健康检查**: http://localhost:8000/api/health +| 服务 | 地址 | +|------|------| +| 前端界面 | http://localhost:3000 | +| Swagger 接口文档 | http://localhost:8000/swagger/index.html | +| 健康检查 | http://localhost:8000/api/health | ## ⚙️ 配置说明 -### 主要配置项 +主要配置项(完整说明请参考 `config.example.yaml`): | 配置项 | 说明 | 示例 | |--------|------|------| -| `app.addr` | 后端服务监听地址 | `:8000` | -| `oauth2.client_id` | OAuth2 客户端 ID | `your_client_id` | -| `database.host` | PostgreSQL 数据库地址 | `127.0.0.1` | -| `database.port` | PostgreSQL 数据库端口 | `5432` | -| `database.username` | PostgreSQL 数据库用户名 | `postgres` | -| `database.password` | PostgreSQL 数据库密码 | `password` | -| `database.database` | PostgreSQL 数据库名称 | `linux_do_credit` | -| `database.ssl_mode` | PostgreSQL SSL 模式 | `disable` | -| `database.application_name` | PostgreSQL 应用标识 | `credit-server` | -| `database.search_path` | PostgreSQL 搜索路径 | `public` | -| `database.default_query_exec_mode` | SQL 缓存模式 | `cache_statement` | -| `redis.host` | Redis 服务器地址 | `127.0.0.1` | - -详细配置说明请参考 `config.example.yaml` 文件。 +| `app.addr` | 后端监听地址 | `:8000` | +| `database.host` | PostgreSQL 主机 | `127.0.0.1` | +| `database.database` | 数据库名称 | `refreshing` | +| `redis.host` | Redis 主机 | `127.0.0.1` | +| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` | +| `oauth2.client_id` | 默认 OIDC 客户端 ID | `your_client_id` | ## 🔧 开发指南 -### 后端开发 +### 后端 ```bash # 运行 API 服务器 go run main.go api -# 运行任务调度器 +# 运行定时任务调度器 go run main.go scheduler -# 运行工作队列 +# 运行异步任务工作进程 go run main.go worker -# 生成 Swagger 文档 +# 修改 Controller 后重新生成 Swagger 文档(必须执行) make swagger -# 代码格式化和检查 +# 代码格式化与检查 make tidy ``` -### 前端开发 +### 前端 ```bash cd frontend -# 开发模式(使用 Turbopack) +# 开发模式(Turbopack) pnpm dev # 构建生产版本 pnpm build -# 启动生产服务 +# 启动生产服务器 pnpm start -# 代码检查和格式化 +# 代码 Lint 和格式化 pnpm lint pnpm format ``` -## 📚 API 文档 +## 📁 项目结构 -API 文档通过 Swagger 自动生成,启动后端服务后可访问: +``` +Refreshing/ +├── main.go # 程序入口(委托给 internal/cmd) +├── config.example.yaml # 配置模板 +├── Makefile # 常用命令(swagger、tidy、license) +├── Dockerfile # 容器镜像构建 +├── docs/ # Swagger 自动生成文档 +├── frontend/ # Next.js 前端应用 +│ ├── app/ # App Router 页面 +│ ├── components/ # React 组件(ui、common、layout) +│ ├── lib/services/ # API 服务层 +│ └── types/ # TypeScript 类型定义 +└── internal/ # Go 后端(private) + ├── cmd/ # CLI 命令(api、scheduler、worker) + ├── apps/ # 业务模块(oauth、user、admin、upload) + ├── model/ # GORM 实体与业务方法 + ├── router/ # HTTP 路由注册 + ├── task/ # 异步任务定义与工作进程 + ├── db/ # 数据库与 Redis 初始化 + ├── storage/ # S3 文件存储抽象层 + └── common/ # 公共工具与响应封装 +``` + +## 📚 接口文档 + +Swagger 接口文档在后端启动后自动可用: ``` http://localhost:8000/swagger/index.html ``` +前端文档中心(路径 `/docs`)内置以下内容: +- **使用指南** — 分步入门教程 +- **接口文档** — 详细接口说明 +- **隐私政策** — 隐私政策模板(请按需自定义) +- **服务条款** — 服务条款模板 + ## 🧪 测试 ```bash # 后端测试 go test ./... -# 前端测试 -cd frontend -pnpm test +# 前端 Lint +cd frontend && pnpm lint ``` ## 🚀 部署 -### Docker 部署 +### Docker ```bash # 构建镜像 -docker build -t linux-do-credit . +docker build -t refreshing . -# 运行容器 -docker run -d -p 8000:8000 linux-do-credit +# 运行(通过卷挂载传入配置文件) +docker run -d -p 8000:8000 \ + -v $(pwd)/config.yaml:/app/config.yaml \ + refreshing api ``` -### 生产环境部署 +### 生产环境 1. 构建前端资源: ```bash @@ -239,25 +273,27 @@ docker run -d -p 8000:8000 linux-do-credit 2. 编译后端程序: ```bash - go build -o credit main.go + go build -o refreshing main.go ``` -3. 配置生产环境的 `config.yaml` +3. 配置生产环境的 `config.yaml`。 4. 启动服务: ```bash - ./credit api + ./refreshing api # HTTP API + ./refreshing scheduler # 定时调度器(可选) + ./refreshing worker # 任务工作进程(可选) ``` ## 🤝 贡献指南 -我们欢迎社区贡献!请在提交代码前阅读: +我们欢迎社区贡献!请在提交代码前阅读以下文档: - [贡献指南](CONTRIBUTING.md) - [行为准则](CODE_OF_CONDUCT.md) - [贡献者许可协议](CLA.md) -### 提交流程 +### 贡献流程 1. Fork 本仓库 2. 创建特性分支 (`git checkout -b feature/your-feature`) @@ -267,18 +303,4 @@ docker run -d -p 8000:8000 linux-do-credit ## 📄 许可证 -本项目基于 [Apache2.0 许可证](LICENSE) 开源。 - -## 🔗 相关链接 - -- [Linux Do 社区](https://linux.do) -- [问题反馈](https://github.com/linux-do/credit/issues) -- [功能请求](https://github.com/linux-do/credit/issues/new?template=feature_request.md) - -## ❤️ 致谢 - -感谢所有为本项目做出贡献的开发者和 Linux Do 社区的支持! - -## 📈 项目趋势 - -[![Star History Chart](https://api.star-history.com/svg?repos=linux-do/credit&type=Date)](https://star-history.com/#linux-do/credit&Date) +本项目基于 [Apache 2.0 许可证](LICENSE) 开源。 diff --git a/config.example.yaml b/config.example.yaml index 91176135..baa03d6b 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -1,88 +1,89 @@ -# LINUX DO Credit Config -# Copy to config.yaml for runtime +# Refreshing — Full-Stack Boilerplate Config +# Copy this file to config.yaml and fill in your values. +# Fields marked with <...> are required; others have sensible defaults. -# App +# ─── Application ──────────────────────────────────────────────────────────────── app: - app_name: "linux-do-credit" - env: "development" # development, testing, production + app_name: "refreshing" + env: "development" # development | testing | production addr: ":8000" - node_id: 1 # 分布式节点 ID (0-1023),不同实例必须不同 + node_id: 1 # Snowflake node ID (0-1023). Must be unique per instance. graceful_shutdown_timeout: 30 - session_cookie_name: "linux_do_credit_session_id" # change this in local dev env - session_secret: "" # you can't change this after first time start - session_domain: ".linux.do" - session_age: 86400 + session_cookie_name: "refreshing_session_id" # Change to something unique before deploy + session_secret: "" # Cannot be changed after first start + session_domain: "" # e.g. ".yourdomain.com" + session_age: 86400 # Session lifetime in seconds (default: 24h) session_secure: false session_http_only: false api_prefix: "/api" frontend_url: "http://localhost:3000" - frontend_pay_url: "http://localhost:3000/paying" -# OAuth2/OIDC(优先) +# ─── Default OAuth2 / OIDC Provider (optional) ────────────────────────────────── +# You can configure additional OIDC providers at runtime via the admin panel. oauth2: client_id: "" client_secret: "" redirect_uri: "" - issuer: "https://connect.linux.do/" # OIDC Issuer URL,用于自动发现端点 - authorization_endpoint: "https://connect.linux.do/oauth2/authorize" - token_endpoint: "https://connect.linux.do/oauth2/token" - user_endpoint: "https://connect.linux.do/api/user" + issuer: "" # OIDC Issuer URL for auto-discovery (recommended) + authorization_endpoint: "" # Leave empty if issuer is set + token_endpoint: "" + user_endpoint: "" -# DB -# 支持两种模式:Standalone(单节点)、Primary-Replica(读写分离) +# ─── PostgreSQL ───────────────────────────────────────────────────────────────── +# Supports Standalone and Primary-Replica (read/write split) modes. database: enabled: true host: "127.0.0.1" port: 5432 username: "postgres" password: "" - database: "linux_do_credit" + database: "refreshing" max_idle_conn: 16 max_open_conn: 128 conn_max_lifetime: 1800 conn_max_idle_time: 600 - log_level: "info" # error, warn, info, debug, silent + log_level: "info" # error | warn | info | debug | silent ssl_mode: "disable" - time_zone: "Asia/Shanghai" - application_name: "pay-server" - # pgb改为true,默认false + time_zone: "UTC" + application_name: "refreshing-server" prefer_simple_protocol: false search_path: "public" statement_cache_capacity: 256 default_query_exec_mode: "cache_statement" slow_threshold: 200ms + # Optional read replicas: # replicas: - # - host: "replica1.db.local" + # - host: "replica1.db.internal" # port: 5432 - # - host: "replica2.db.local" + # - host: "replica2.db.internal" # port: 5432 -# clickhouse +# ─── ClickHouse (optional) ────────────────────────────────────────────────────── clickhouse: enabled: false hosts: - "127.0.0.1:9000" username: "default" password: "" - database: "linux_do_credit" + database: "refreshing" max_idle_conn: 10 max_open_conn: 100 conn_max_lifetime: 3600 dial_timeout: 5 block_buffer_size: 10 -# Redis -# 支持三种模式:Standalone(单节点)、Sentinel(高可用)、Cluster(水平扩展) +# ─── Redis ────────────────────────────────────────────────────────────────────── +# Supports Standalone, Sentinel (HA), and Cluster modes. redis: enabled: true addrs: - "127.0.0.1:6379" username: "" password: "" - db: 0 # Cluster 模式忽略此项 - cluster_mode: false # true 启用 Cluster 模式 - master_name: "" # 非空启用 Sentinel 模式 - key_prefix: "credit:" + db: 0 # Ignored in Cluster mode + cluster_mode: false # Set true to enable Cluster mode + master_name: "" # Set non-empty to enable Sentinel mode + key_prefix: "refreshing:" pool_size: 100 min_idle_conn: 10 dial_timeout: 5 @@ -92,28 +93,22 @@ redis: pool_timeout: 4 conn_max_idle_time: 300 -# Log +# ─── Logging ──────────────────────────────────────────────────────────────────── log: - level: "info" # debug, info, warn, error, fatal, panic - format: "json" # text, json - output: "stdout" # stdout, file + level: "info" # debug | info | warn | error | fatal | panic + format: "json" # text | json + output: "stdout" # stdout | file file_path: "./logs/app.log" - max_size: 100 - max_age: 30 + max_size: 100 # MB per log file + max_age: 30 # Days to retain old log files max_backups: 10 compress: true -# Scheduler +# ─── Task Scheduler (Cron) ────────────────────────────────────────────────────── scheduler: - update_user_gamification_scores_task_cron: "0 2 * * *" - dispute_auto_refund_dispatch_interval_seconds: 3 - auto_refund_expired_disputes_task_cron: "0 0 * * *" - sync_orders_to_clickhouse_task_cron: "10 0 * * *" - refund_expired_red_envelopes_task_cron: "0 1 * * *" - cleanup_unused_uploads_task_cron: "0 */2 * * *" - settle_pending_payments_task_cron: "0 * * * *" + cleanup_unused_uploads_task_cron: "0 */2 * * *" # Clean up orphaned upload records -# Worker +# ─── Async Task Worker ────────────────────────────────────────────────────────── worker: concurrency: 20 strict_priority: false @@ -124,41 +119,23 @@ worker: priority: 5 - name: default priority: 3 - # 积分更新速率限制:rate 次/period 秒 - gamification_score_rate_limit: - rate: 1 # 允许的请求次数 - period: 3 # 时间周期(秒) -# linuxDo -linuxDo: - api_key: "" - -# OpenAPI Risk -openapi_risk: - enabled: false - base_url: "https://audit.example.com" - username: "" - password: "" - cache_ttl_seconds: 3600 - prompt_risk_levels: [] - block_risk_levels: [] - -# OpenTelemetry +# ─── OpenTelemetry Tracing ────────────────────────────────────────────────────── otel: - sampling_rate: 0.1 # 采样率 0.0-1.0 + sampling_rate: 0.1 # Trace sampling rate (0.0 – 1.0) -# S3 Compatible Storage -# 支持 AWS S3、MinIO、Cloudflare R2、腾讯 COS 等 S3 兼容存储 +# ─── S3-Compatible File Storage ───────────────────────────────────────────────── +# Compatible with AWS S3, MinIO, Cloudflare R2, Tencent COS, etc. s3: - enabled: true - endpoint: "https://.r2.cloudflarestorage.com" + enabled: false + endpoint: "https://.r2.cloudflarestorage.com" region: "auto" - bucket: "" + bucket: "" access_key_id: "" secret_access_key: "" - path_style: false # MinIO 等自托管服务设为 true - key_prefix: "" # 对象 key 前缀,如 "uploads/",可用于分目录存储 - cdn_url: "" # CDN 域名(如 https://cdn.example.com),为空则直接读 S3 + path_style: false # Set true for self-hosted S3 (e.g. MinIO) + key_prefix: "" # Optional prefix for all object keys, e.g. "uploads/" + cdn_url: "" # CDN base URL (e.g. https://cdn.example.com); falls back to S3 if empty local_cache: enabled: false cache_dir: "./s3_cache" diff --git a/docs/docs.go b/docs/docs.go index 3471fa1b..851334fa 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -1492,6 +1492,393 @@ const docTemplate = `{ } } }, + "/api/v1/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "要上传的文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "额外的 JSON 格式元数据", + "name": "metadata", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "上传成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "400": { + "description": "请求参数错误或文件受限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/login": { "post": { "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", @@ -1740,6 +2127,32 @@ const docTemplate = `{ } } }, + "model.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "string" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "model.AuthSource": { "type": "object", "properties": { @@ -1850,6 +2263,129 @@ const docTemplate = `{ "TrustLevelLeader" ] }, + "model.Upload": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "extension": { + "description": "文件后缀名 (不含点,如 png, pdf)", + "type": "string" + }, + "file_name": { + "description": "原始文件名 (例如: image.png)", + "type": "string" + }, + "file_path": { + "description": "文件相对路径 / S3 Key", + "type": "string" + }, + "file_size": { + "description": "文件大小(字节)", + "type": "integer" + }, + "hash": { + "description": "文件哈希 (SHA-256/MD5,可用于排重)", + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "description": "业务扩展元数据", + "allOf": [ + { + "$ref": "#/definitions/model.UploadMetadata" + } + ] + }, + "mime_type": { + "description": "媒体类型 (MIME, 如 image/png)", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/model.UploadStatus" + } + ] + }, + "storage_driver": { + "description": "存储引擎驱动 (如 local, s3, oss)", + "type": "string" + }, + "type": { + "description": "业务标识类型 (如 avatar, doc, attachment)", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "model.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "description": "存储桶名称 (适用于 S3 等)", + "type": "string" + }, + "client_ip": { + "description": "上传者 IP", + "type": "string" + }, + "duration": { + "description": "音视频时长 (s)", + "type": "number" + }, + "extra": { + "description": "其它任意业务自定义元数据", + "type": "object", + "additionalProperties": {} + }, + "height": { + "description": "图像/视频高度 (px)", + "type": "integer" + }, + "original_mime": { + "description": "原始 MIME 类型", + "type": "string" + }, + "user_agent": { + "description": "上传者的 UA", + "type": "string" + }, + "width": { + "description": "图像/视频宽度 (px)", + "type": "integer" + } + } + }, + "model.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, "oauth.AuthSourceView": { "type": "object", "properties": { @@ -2057,6 +2593,29 @@ const docTemplate = `{ } } }, + "upload.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "user.createTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "user.listUsersResponse": { "type": "object", "properties": { @@ -2099,6 +2658,17 @@ const docTemplate = `{ } } }, + "user.tokenResponse": { + "type": "object", + "properties": { + "record": { + "$ref": "#/definitions/model.AccessToken" + }, + "token": { + "type": "string" + } + } + }, "user.updateUserStatusRequest": { "type": "object", "properties": { diff --git a/docs/swagger.json b/docs/swagger.json index 0efa579b..45c6bd2e 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -1485,6 +1485,393 @@ } } }, + "/api/v1/upload": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重", + "consumes": [ + "multipart/form-data" + ], + "produces": [ + "application/json" + ], + "tags": [ + "upload" + ], + "summary": "上传文件", + "parameters": [ + { + "type": "file", + "description": "要上传的文件", + "name": "file", + "in": "formData", + "required": true + }, + { + "type": "string", + "description": "业务分类 (例如: avatar, attachment, doc,默认为 generic)", + "name": "type", + "in": "formData" + }, + { + "type": "string", + "description": "额外的 JSON 格式元数据", + "name": "metadata", + "in": "formData" + } + ], + "responses": { + "200": { + "description": "上传成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/model.Upload" + } + } + } + ] + } + }, + "400": { + "description": "请求参数错误或文件受限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/download/batch": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突", + "consumes": [ + "application/json" + ], + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "批量打包下载", + "parameters": [ + { + "description": "包含文件 ID 数组的请求体", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/upload.batchDownloadRequest" + } + } + ], + "responses": { + "200": { + "description": "成功下载打包后的 ZIP", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "打包失败", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/upload/download/{id}": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载", + "produces": [ + "application/octet-stream" + ], + "tags": [ + "upload" + ], + "summary": "下载单文件", + "parameters": [ + { + "type": "string", + "description": "文件 ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "成功下载文件", + "schema": { + "type": "file" + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "404": { + "description": "文件不存在", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + }, + "500": { + "description": "服务内部错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens": { + "get": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "返回当前登录用户的所有 active access tokens(脱敏后)", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "获取当前用户的 AccessToken 列表", + "responses": { + "200": { + "description": "令牌列表", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/model.AccessToken" + } + } + } + } + ] + } + }, + "401": { + "description": "未登录", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + }, + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "创建一个新的 AccessToken", + "parameters": [ + { + "description": "令牌名称", + "name": "request", + "in": "body", + "required": true, + "schema": { + "$ref": "#/definitions/user.createTokenRequest" + } + } + ], + "responses": { + "200": { + "description": "新建令牌成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误或超限", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}": { + "delete": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "撤销并删除一个属于当前用户的 API 访问令牌", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "删除一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "删除成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "type": "string" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, + "/api/v1/user/access-tokens/{id}/rotate": { + "post": { + "security": [ + { + "SessionCookie": [] + } + ], + "description": "轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效", + "produces": [ + "application/json" + ], + "tags": [ + "user" + ], + "summary": "轮换一个 AccessToken", + "parameters": [ + { + "type": "string", + "description": "令牌ID", + "name": "id", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "令牌轮换成功", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/util.ResponseAny" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/user.tokenResponse" + } + } + } + ] + } + }, + "400": { + "description": "参数错误", + "schema": { + "$ref": "#/definitions/util.ResponseAny" + } + } + } + } + }, "/api/v1/user/login": { "post": { "description": "使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。", @@ -1733,6 +2120,32 @@ } } }, + "model.AccessToken": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "last_used_at": { + "type": "string" + }, + "masked_token": { + "type": "string" + }, + "name": { + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "integer" + } + } + }, "model.AuthSource": { "type": "object", "properties": { @@ -1843,6 +2256,129 @@ "TrustLevelLeader" ] }, + "model.Upload": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "extension": { + "description": "文件后缀名 (不含点,如 png, pdf)", + "type": "string" + }, + "file_name": { + "description": "原始文件名 (例如: image.png)", + "type": "string" + }, + "file_path": { + "description": "文件相对路径 / S3 Key", + "type": "string" + }, + "file_size": { + "description": "文件大小(字节)", + "type": "integer" + }, + "hash": { + "description": "文件哈希 (SHA-256/MD5,可用于排重)", + "type": "string" + }, + "id": { + "type": "string", + "example": "0" + }, + "metadata": { + "description": "业务扩展元数据", + "allOf": [ + { + "$ref": "#/definitions/model.UploadMetadata" + } + ] + }, + "mime_type": { + "description": "媒体类型 (MIME, 如 image/png)", + "type": "string" + }, + "status": { + "description": "状态", + "allOf": [ + { + "$ref": "#/definitions/model.UploadStatus" + } + ] + }, + "storage_driver": { + "description": "存储引擎驱动 (如 local, s3, oss)", + "type": "string" + }, + "type": { + "description": "业务标识类型 (如 avatar, doc, attachment)", + "type": "string" + }, + "updated_at": { + "type": "string" + }, + "user_id": { + "type": "string", + "example": "0" + } + } + }, + "model.UploadMetadata": { + "type": "object", + "properties": { + "bucket": { + "description": "存储桶名称 (适用于 S3 等)", + "type": "string" + }, + "client_ip": { + "description": "上传者 IP", + "type": "string" + }, + "duration": { + "description": "音视频时长 (s)", + "type": "number" + }, + "extra": { + "description": "其它任意业务自定义元数据", + "type": "object", + "additionalProperties": {} + }, + "height": { + "description": "图像/视频高度 (px)", + "type": "integer" + }, + "original_mime": { + "description": "原始 MIME 类型", + "type": "string" + }, + "user_agent": { + "description": "上传者的 UA", + "type": "string" + }, + "width": { + "description": "图像/视频宽度 (px)", + "type": "integer" + } + } + }, + "model.UploadStatus": { + "type": "string", + "enum": [ + "pending", + "used", + "deleted" + ], + "x-enum-comments": { + "UploadStatusDeleted": "已删除", + "UploadStatusPending": "待使用", + "UploadStatusUsed": "已使用" + }, + "x-enum-varnames": [ + "UploadStatusPending", + "UploadStatusUsed", + "UploadStatusDeleted" + ] + }, "oauth.AuthSourceView": { "type": "object", "properties": { @@ -2050,6 +2586,29 @@ } } }, + "upload.batchDownloadRequest": { + "type": "object", + "required": [ + "ids" + ], + "properties": { + "ids": { + "type": "array", + "minItems": 1, + "items": { + "type": "string" + } + } + } + }, + "user.createTokenRequest": { + "type": "object", + "properties": { + "name": { + "type": "string" + } + } + }, "user.listUsersResponse": { "type": "object", "properties": { @@ -2092,6 +2651,17 @@ } } }, + "user.tokenResponse": { + "type": "object", + "properties": { + "record": { + "$ref": "#/definitions/model.AccessToken" + }, + "token": { + "type": "string" + } + } + }, "user.updateUserStatusRequest": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 308e9fe3..bec3f4b2 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -26,6 +26,23 @@ definitions: is_active: type: boolean type: object + model.AccessToken: + properties: + created_at: + type: string + id: + type: integer + last_used_at: + type: string + masked_token: + type: string + name: + type: string + updated_at: + type: string + user_id: + type: integer + type: object model.AuthSource: properties: client_id: @@ -101,6 +118,93 @@ definitions: - TrustLevelUser - TrustLevelActiveUser - TrustLevelLeader + model.Upload: + properties: + created_at: + type: string + extension: + description: 文件后缀名 (不含点,如 png, pdf) + type: string + file_name: + description: '原始文件名 (例如: image.png)' + type: string + file_path: + description: 文件相对路径 / S3 Key + type: string + file_size: + description: 文件大小(字节) + type: integer + hash: + description: 文件哈希 (SHA-256/MD5,可用于排重) + type: string + id: + example: "0" + type: string + metadata: + allOf: + - $ref: '#/definitions/model.UploadMetadata' + description: 业务扩展元数据 + mime_type: + description: 媒体类型 (MIME, 如 image/png) + type: string + status: + allOf: + - $ref: '#/definitions/model.UploadStatus' + description: 状态 + storage_driver: + description: 存储引擎驱动 (如 local, s3, oss) + type: string + type: + description: 业务标识类型 (如 avatar, doc, attachment) + type: string + updated_at: + type: string + user_id: + example: "0" + type: string + type: object + model.UploadMetadata: + properties: + bucket: + description: 存储桶名称 (适用于 S3 等) + type: string + client_ip: + description: 上传者 IP + type: string + duration: + description: 音视频时长 (s) + type: number + extra: + additionalProperties: {} + description: 其它任意业务自定义元数据 + type: object + height: + description: 图像/视频高度 (px) + type: integer + original_mime: + description: 原始 MIME 类型 + type: string + user_agent: + description: 上传者的 UA + type: string + width: + description: 图像/视频宽度 (px) + type: integer + type: object + model.UploadStatus: + enum: + - pending + - used + - deleted + type: string + x-enum-comments: + UploadStatusDeleted: 已删除 + UploadStatusPending: 待使用 + UploadStatusUsed: 已使用 + x-enum-varnames: + - UploadStatusPending + - UploadStatusUsed + - UploadStatusDeleted oauth.AuthSourceView: properties: client_secret_configured: @@ -239,6 +343,21 @@ definitions: type: type: string type: object + upload.batchDownloadRequest: + properties: + ids: + items: + type: string + minItems: 1 + type: array + required: + - ids + type: object + user.createTokenRequest: + properties: + name: + type: string + type: object user.listUsersResponse: properties: total: @@ -266,6 +385,13 @@ definitions: username: type: string type: object + user.tokenResponse: + properties: + record: + $ref: '#/definitions/model.AccessToken' + token: + type: string + type: object user.updateUserStatusRequest: properties: is_active: @@ -1204,6 +1330,237 @@ paths: summary: 获取当前登录用户信息 tags: - oauth + /api/v1/upload: + post: + consumes: + - multipart/form-data + description: 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 + parameters: + - description: 要上传的文件 + in: formData + name: file + required: true + type: file + - description: '业务分类 (例如: avatar, attachment, doc,默认为 generic)' + in: formData + name: type + type: string + - description: 额外的 JSON 格式元数据 + in: formData + name: metadata + type: string + produces: + - application/json + responses: + "200": + description: 上传成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/model.Upload' + type: object + "400": + description: 请求参数错误或文件受限 + schema: + $ref: '#/definitions/util.ResponseAny' + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 上传文件 + tags: + - upload + /api/v1/upload/download/{id}: + get: + description: 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 + parameters: + - description: 文件 ID + in: path + name: id + required: true + type: string + produces: + - application/octet-stream + responses: + "200": + description: 成功下载文件 + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + "404": + description: 文件不存在 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 服务内部错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 下载单文件 + tags: + - upload + /api/v1/upload/download/batch: + post: + consumes: + - application/json + description: 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 + parameters: + - description: 包含文件 ID 数组的请求体 + in: body + name: request + required: true + schema: + $ref: '#/definitions/upload.batchDownloadRequest' + produces: + - application/octet-stream + responses: + "200": + description: 成功下载打包后的 ZIP + schema: + type: file + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + "500": + description: 打包失败 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 批量打包下载 + tags: + - upload + /api/v1/user/access-tokens: + get: + description: 返回当前登录用户的所有 active access tokens(脱敏后) + produces: + - application/json + responses: + "200": + description: 令牌列表 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + items: + $ref: '#/definitions/model.AccessToken' + type: array + type: object + "401": + description: 未登录 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 获取当前用户的 AccessToken 列表 + tags: + - user + post: + consumes: + - application/json + description: 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。 + parameters: + - description: 令牌名称 + in: body + name: request + required: true + schema: + $ref: '#/definitions/user.createTokenRequest' + produces: + - application/json + responses: + "200": + description: 新建令牌成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误或超限 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 创建一个新的 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}: + delete: + description: 撤销并删除一个属于当前用户的 API 访问令牌 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 删除成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + type: string + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 删除一个 AccessToken + tags: + - user + /api/v1/user/access-tokens/{id}/rotate: + post: + description: 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 + parameters: + - description: 令牌ID + in: path + name: id + required: true + type: string + produces: + - application/json + responses: + "200": + description: 令牌轮换成功 + schema: + allOf: + - $ref: '#/definitions/util.ResponseAny' + - properties: + data: + $ref: '#/definitions/user.tokenResponse' + type: object + "400": + description: 参数错误 + schema: + $ref: '#/definitions/util.ResponseAny' + security: + - SessionCookie: [] + summary: 轮换一个 AccessToken + tags: + - user /api/v1/user/login: post: consumes: diff --git a/frontend/app/(main)/settings/access-token/page.tsx b/frontend/app/(main)/settings/access-token/page.tsx new file mode 100644 index 00000000..61a0f6d0 --- /dev/null +++ b/frontend/app/(main)/settings/access-token/page.tsx @@ -0,0 +1,7 @@ +"use client" + +import {AccessTokenMain} from "@/components/common/settings/access-token" + +export default function AccessTokenPage() { + return +} diff --git a/frontend/app/(main)/settings/files/page.tsx b/frontend/app/(main)/settings/files/page.tsx new file mode 100644 index 00000000..a91770c4 --- /dev/null +++ b/frontend/app/(main)/settings/files/page.tsx @@ -0,0 +1,5 @@ +import {FilesMain} from "@/components/common/settings/files" + +export default function FilesPage() { + return +} diff --git a/frontend/app/(main)/settings/page.tsx b/frontend/app/(main)/settings/page.tsx index ed581087..2e7353ce 100644 --- a/frontend/app/(main)/settings/page.tsx +++ b/frontend/app/(main)/settings/page.tsx @@ -2,7 +2,7 @@ import Link from "next/link" import {Card, CardContent, CardDescription, CardTitle} from "@/components/ui/card" -import {Bell, Loader2, Palette, Shield, UserRound} from "lucide-react" +import {Bell, Key, Loader2, Palette, Shield, UserRound} from "lucide-react" import {useAuth} from "@/components/providers/auth-provider" /* 设置项 */ @@ -14,6 +14,13 @@ const settingsItems = [ href: "/settings/profile", category: "个人设置", }, + { + title: "访问令牌 (AccessToken)", + description: "管理用于 API 访问的个人 Token", + icon: Key, + href: "/settings/access-token", + category: "个人设置", + }, { title: "通知设置", description: "设置您的通知偏好", diff --git a/frontend/components/common/docs/api.tsx b/frontend/components/common/docs/api.tsx index 79fb6051..95580e82 100644 --- a/frontend/components/common/docs/api.tsx +++ b/frontend/components/common/docs/api.tsx @@ -1,15 +1,15 @@ -import { type PolicySection } from "./types" -import { CodeBlock } from "@/components/ui/code-block" +import {type PolicySection} from "./types" +import {CodeBlock} from "@/components/ui/code-block" import { DocsTable, - DocsTableHeader, DocsTableBody, - DocsTableHead, - DocsTableRow, DocsTableCell, + DocsTableHead, + DocsTableHeader, + DocsTableRow, } from "@/components/ui/docs-table" -export const DOCS_LAST_UPDATED = "2026-04-20" +export const DOCS_LAST_UPDATED = "2026-06-07" /** * ------------------------------------------------------------------ @@ -18,572 +18,251 @@ export const DOCS_LAST_UPDATED = "2026-04-20" */ export const apiSections: PolicySection[] = [ { - value: "official-service", - title: "1. 官方 LDC 接口", + value: "api-specs", + title: "1. 接口规范与鉴权说明", content: (
-

官方原生接口,使用 Ed25519 签名算法,安全性更高

+

平台统一接口调用格式规范以及开发者访问令牌鉴权方式说明

-

1.1 概览

-
    -
  • 协议:官方 LDC 支付协议
  • -
  • 服务类型:支持 type=ldcpay
  • -
  • 网关基址:https://credit.linux.do/epay
  • -
  • 签名方式:Ed25519 非对称加密
  • -
- -

1.2 对接流程

-
    -
  1. 控制台创建应用,配置 client_id 并在应用设置中上传商户 Ed25519 公钥
  2. -
  3. 根据“签名算法”及商户私钥生成 sign
  4. -
  5. 调用 /pay/submit 发起积分流转请求
  6. -
  7. 认证完成后,通过异步回调或轮询接口同步状态
  8. -
- -

1.3 鉴权与签名

-

1.3.1 签名算法

-
-
    -
  1. 取除 sign 以外的所有非空请求参数
  2. -
  3. 将参数按参数名 ASCII 码从到大排序(字典序)
  4. -
  5. 使用 k1=v1&k2=v2... 格式拼接成字符串
  6. -
  7. 将 应用密钥 (Client Secret) 直接追加到字符串末尾
  8. -
  9. 使用商户私钥对最终字符串进行 Ed25519 签名
  10. -
  11. 将签名结果转换成 Base64 编码作为 sign 参数
  12. -
- -
- -

1.4 积分流转服务

-
    -
  • 方法:POST /epay/pay/submit.php
  • -
  • 编码:application/json 或 application/x-www-form-urlencoded
  • -
- +

1.1 统一响应格式

+

系统所有 API 接口均遵循标准 JSON 响应结构:

- 参数 - 必填 + 字段 + 类型 说明 - client_id - 是 - 应用客户端 ID + error_msg + string + 错误信息。请求成功时为空字符串 `""`,失败时包含错误详情描述。 - type - 是 - 固定 ldcpay - - - out_trade_no - 是 - 业务单号 - - - money - 是 - 积分数量,必须保留两位小数(比如,10.00) - - - order_name - 是 - 商品名称 - - - notify_url - 否 - 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url - - - return_url - 否 - 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri - - - sign - 是 - 按“签名算法”生成的 Base64 签名串 + data + any + 接口返回的具体数据内容。请求失败或无数据返回时为 `null`。 -

1.5 其他接口

-

其他接口定义请参考 3. 其他接口。

- -
- ), - children: [ - { value: "1-1-overview", title: "1.1 概览" }, - { value: "1-2-flow", title: "1.2 对接流程" }, - { value: "1-3-auth-sign", title: "1.3 鉴权与签名" }, - { value: "1-4-submit", title: "1.4 积分流转服务" }, - { value: "1-5-others", title: "1.5 其他接口" }, - ] - }, - { - value: "epay-compatibility", - title: "2. 易支付兼容接口", - content: ( -
-
-

兼容易支付、CodePay、VPay 等支付协议

-
- -

2.1 概览

-
    -
  • 协议:EasyPay / CodePay / VPay 兼容协议
  • -
  • 服务类型:仅支持 type=epay
  • -
  • 网关基址:https://credit.linux.do/epay
  • -
  • 订单有效期:取系统配置 order_expire_minutes(平台端设置)
  • -
- -

2.2 常见错误

-
    -
  • 不支持的请求类型:type 仅允许 epay
  • -
  • 签名验证失败:参与签名字段与请求体需一致,密钥直接拼接
  • -
  • 金额必须大于0 / 积分小数位数不能超过2位
  • -
  • 订单已过期:超出系统配置有效期
  • -
  • 订单不存在或已完成:订单号错误、已退回或已完成
  • -
  • 余额不足:余额退回时用户积分不足
  • -
- -

2.3 对接流程

-
    -
  1. 控制台创建 API Key,记录 pid、key,配置回调地址
  2. -
  3. 按“签名算法”生成 sign,调用 /epay/pay/submit.php 创建积分流转服务并跳转认证界面
  4. -
  5. 可通过 /epay/api.php 轮询结果,或等待异步回调
  6. -
  7. 退回服务时,携带同一 trade_no 和原积分数量,调用积分退回接口
  8. -
  9. 回调验签通过后返回 success 完成闭环
  10. -
- -

2.4 鉴权与签名

-

2.4.1 API Key

-
    -
  • pid:Client ID
  • -
  • key:Client Secret(妥善保管)
  • -
  • notify_url / return_url:应用级默认回调地址(兜底);创建订单时可在请求中传同名字段作为订单级覆盖,未传时回退应用配置。
  • -
- -

2.4.2 签名算法

-
-
    -
  1. 取所有非空字段(排除 sign、sign_type 字段)
  2. -
  3. 将上述字段按 ASCII 升序,依次拼成 k1=v1&k2=v2
  4. -
  5. 在末尾追加应用密钥:k1=v1&k2=v2{"{secret}"}
  6. -
  7. 整体进行 MD5,取小写十六进制作为 sign
  8. -
- -
- -

2.5 积分流转服务

-
    -
  • 方法:POST /epay/pay/submit.php
  • -
  • 编码:application/json 或 application/x-www-form-urlencoded
  • -
  • 成功:验签通过后,平台自动创建积分流转服务,并跳转到认证界面(Location=https://credit.linux.do/paying?order_no=...)
  • -
  • 失败:返回 JSON {`{"error_msg":"...", "data":null}`}
  • -
- - - - - 参数 - 必填 - 说明 - - - - - pid - 是 - Client ID - - - type - 是 - 固定 epay - - - out_trade_no - 否 - 业务单号,建议全局唯一 - - - name - 是 - 标题,最多 64 字符 - - - money - 是 - 积分数量,最多 2 位小数 - - - notify_url - 否 - 会参与签名;可选订单级异步通知地址。长度不超过 100,需为合法 URL。传入后支付成功优先回调该地址,未传则使用应用 notify_url - - - return_url - 否 - 会参与签名;可选订单级回跳地址。长度不超过 100,需为合法 URL。传入后支付成功页面优先跳转该地址,未传则使用应用 redirect_uri - - - device - 否 - 终端标识,可选 - - - sign - 是 - 按“签名算法”生成 - - - sign_type - 否 - 固定 MD5 - - - - -

请求示例:

- - -

2.6 其他接口

-

其他接口定义请参考 3. 其他接口。

- -
- ), - children: [ - { value: "2-1-overview", title: "2.1 概览" }, - { value: "2-2-common-errors", title: "2.2 常见错误" }, - { value: "2-3-flow", title: "2.3 对接流程" }, - { value: "2-4-auth-sign", title: "2.4 鉴权与签名" }, - { value: "2-5-submit", title: "2.5 积分流转服务" }, - { value: "2-6-others", title: "2.6 其他接口" }, - ] - }, - { - value: "common-services", - title: "3. 其他接口", - content: ( -
-
-

官方接口与易支付兼容接口公用接口。

-
- -

3.1 订单查询

-
    -
  • 方法:GET /epay/api.php
  • -
  • 认证:pid + key
  • -
  • 说明:out_trade_no 必填;act 可传 order,后端不强校验。
  • -
- - - - - 参数 - 必填 - 说明 - - - - - act - 否 - 可选字段,建议 order - - - pid - 是 - Client ID - - - key - 是 - Client Secret - - - out_trade_no - 是 - 业务单号 - - - - -

成功响应:

- -

补充:status 1=成功,0=失败/处理中;不存在会返回 HTTP 404 且 {`{"code":-1,"msg":"服务不存在或已完成"}`}。

- -

3.2 订单退款

-
    -
  • 方法:POST /epay/api.php
  • -
  • 编码:application/json 或 application/x-www-form-urlencoded
  • -
  • 限制:仅支持对已成功的积分流转服务进行积分的全额退回
  • -
- -
- - - - 参数 - 必填 - 说明 - - - - - pid - 是 - Client ID - - - key - 是 - Client Secret - - - trade_no - 是 - 编号 - - - money - 是 - 必须等于原积分流转服务的积分数量 - - - out_trade_no - 否 - 业务单号(兼容) - - - -
- -

响应:

- -

常见失败:服务不存在/未认证、金额不合法(<=0 或小数超过 2 位)。

- - -

3.3 异步通知

-
    -
  • 触发:认证成功后;失败自动重试,最多 5 次(单次 30s 超时)
  • -
  • 目标:订单级 notify_url(如有)优先,否则回退到创建应用时设置的 notify_url
  • -
  • 方式:HTTP GET
  • -
- -
- - - - 参数 - 说明 - - - - - pid - Client ID - - - trade_no - 编号 - - - out_trade_no - 业务单号 - - - type - 固定 epay - - - name - 标题 - - - money - 积分数量,最多 2 位小数 - - - trade_status - 固定 TRADE_SUCCESS - - - sign - 按“签名算法”生成 - - - -
-

应用需返回 HTTP 200 且响应体为 success(大小写不敏感),否则视为失败并继续重试。

- -

3.4 商户分发接口

-
    -
  • 方法:POST /lpay/distribute
  • -
  • 编码:application/json
  • -
  • 认证:Basic Auth (使用 client_id:client_secret 进行 Base64 编码)
  • -
- - - - - 参数 - 必填 - 说明 - - - - - user_id - 是 - 收款人用户 ID (数字) - - - username - 是 - 收款人用户名 (用于二次校验) - - - amount - 是 - 分发积分数量,最多 2 位小数 - - - out_trade_no - 否 - 商户自定义单号 - - - remark - 否 - 分发备注 - - - -

成功响应:{`{"code":1, "data":{"trade_no":"...", "out_trade_no":"..."}}`}

- -

3.5 用户余额统计

-
    -
  • 方法:GET /api/v1/dashboard/stats/user-balance
  • -
  • 认证:无需鉴权(公开接口)
  • -
  • 说明:获取平台所有用户可用余额的统计数据,结果有缓存,TTL 由系统配置决定
  • -
- -

成功响应:

+

成功响应示例:

+

失败响应示例:

+ + +

1.2 鉴权方式

+

除了公共公开接口(如登录、注册、配置)外,受保护的接口需要携带凭证才能正常访问:

+
    +
  • Session 凭证:浏览器环境下支持利用常规 Session Cookie 会话保持登录。
  • +
  • AccessToken 令牌:供后台调用或第三方应用集成使用。客户端生成 API 访问令牌后,需要在请求头(Request Header)中携带以进行身份校验。
  • +
+
+

支持携带令牌的请求头格式(二选一):

+
    +
  • Authorization: Bearer at_xxx
  • +
  • X-Access-Token: at_xxx
  • +
+
+
+ ), + children: [ + { value: "1-1-response-format", title: "1.1 统一响应格式" }, + { value: "1-2-authentication", title: "1.2 鉴权方式" }, + ] + }, + { + value: "auth-apis", + title: "2. 用户与认证接口", + content: ( +
+

2.1 用户注册

+

接口:POST /api/v1/user/register

+

说明:注册本地账户(在后台注册开关开启状态下)。

- 字段 + 参数 + 必填 + 类型 说明 - total_count - 统计用户总数 + username + 是 + string + 用户名,必须唯一且无空格。 - total_amount - 所有用户可用余额之和 + password + 是 + string + 密码,长度必须大于等于 8 位。 - avg_amount - 平均余额 - - - median_amount - 余额中位数 - - - min_amount - 最小余额 - - - max_amount - 最大余额 - - - std_dev - 余额标准差 + nickname + 否 + string + 昵称。未传时默认与用户名一致。 + +

2.2 密码登录

+

接口:POST /api/v1/user/login

+

说明:通过常规用户名密码进行登录校验,成功后建立 Session Cookie 会话。

+ + + + 参数 + 必填 + 类型 + 说明 + + + + + username + 是 + string + 用户名 + + + password + 是 + string + 密码 + + + + +

2.3 退出登录

+

接口:GET /api/v1/user/logout

+

说明:销毁当前会话 Cookie 并退出登录状态。

+ +

2.4 获取个人资料

+

接口:GET /api/v1/user/self

+

说明:获取当前登录账户的基本数据模型(包含 ID、角色、昵称等)。

), children: [ - { value: "3-1-order", title: "3.1 订单查询" }, - { value: "3-2-refund", title: "3.2 订单退款" }, - { value: "3-3-notify", title: "3.3 异步通知" }, - { value: "3-4-distribute", title: "3.4 商户分发接口" }, - { value: "3-5-user-balance", title: "3.5 用户余额统计" }, + { value: "2-1-register", title: "2.1 用户注册" }, + { value: "2-2-login", title: "2.2 密码登录" }, + { value: "2-3-logout", title: "2.3 退出登录" }, + { value: "2-4-profile", title: "2.4 获取个人资料" }, ] }, + { + value: "token-apis", + title: "3. 个人访问令牌 (AccessToken) 接口", + content: ( +
+
+

AccessToken 管理相关接口均要求通过 Session 登录后调用,支持普通用户权限。

+
+ +

3.1 获取令牌列表

+

接口:GET /api/v1/user/access-tokens

+

说明:查询当前用户已创建的所有令牌详情(令牌明文已被脱敏)。

+ +

3.2 新建访问令牌

+

接口:POST /api/v1/user/access-tokens

+

参数:JSON Body {`{"name": "token名称"}`}

+

说明:生成一个全新访问令牌。返回体中包含一次性明文 Token,切勿遗失。

+

成功返回样例:

+ + +

3.3 撤销/删除令牌

+

接口:DELETE /api/v1/user/access-tokens/:id

+

说明:通过 ID 物理删除对应访问令牌,该令牌将立即失效。

+ +

3.4 轮换令牌密钥

+

接口:POST /api/v1/user/access-tokens/:id/rotate

+

说明:轮换指定令牌的物理密钥值。系统将废弃原有密钥,返回新生成的明文 Token,并将 `last_used_at` 置空,令牌名称与 ID 保持一致。

+
+ ), + children: [ + { value: "3-1-list-token", title: "3.1 获取令牌列表" }, + { value: "3-2-create-token", title: "3.2 新建访问令牌" }, + { value: "3-3-delete-token", title: "3.3 撤销/删除令牌" }, + { value: "3-4-rotate-token", title: "3.4 轮换令牌密钥" }, + ] + }, + { + value: "config-apis", + title: "4. 公共配置与管理接口", + content: ( +
+

4.1 公共系统配置

+

接口:GET /api/v1/config/public

+

说明:无感获取当前系统的公开业务设置(如注册是否开启、密码登录是否开启)。供前端页面动态渲染使用。

+

返回数据结构样例:

+ + +

4.2 系统配置项 CRUD (管理员)

+

说明:用于在后台对 `system_configs` 配置进行动态变更,要求管理员权限会话调用。

+
    +
  • 获取配置列表:GET /api/v1/admin/system-configs?type=system
  • +
  • 新建配置项:POST /api/v1/admin/system-configs
  • +
  • 修改指定配置值:PUT /api/v1/admin/system-configs/:key
  • +
  • 删除配置项:DELETE /api/v1/admin/system-configs/:key
  • +
+
+ ), + children: [ + { value: "4-1-public-config", title: "4.1 公共系统配置" }, + { value: "4-2-admin-configs", title: "4.2 系统配置项 CRUD (管理员)" }, + ] + } ] diff --git a/frontend/components/common/docs/how-to-use.tsx b/frontend/components/common/docs/how-to-use.tsx index a22419e6..5189d31c 100644 --- a/frontend/components/common/docs/how-to-use.tsx +++ b/frontend/components/common/docs/how-to-use.tsx @@ -1,13 +1,5 @@ -import { type PolicySection } from "./types" -import { CodeBlock } from "@/components/ui/code-block" -import { - DocsTable, - DocsTableHeader, - DocsTableBody, - DocsTableHead, - DocsTableRow, - DocsTableCell, -} from "@/components/ui/docs-table" +import {type PolicySection} from "./types" +import {CodeBlock} from "@/components/ui/code-block" /** * ------------------------------------------------------------------ @@ -21,261 +13,96 @@ export const howToUseSections: PolicySection[] = [ content: (
-

为社区开发者与用户提供完整的平台使用说明

+

为开发者提供通用全栈开发脚手架 (Boilerplate) 平台使用说明

    -
  • 身份认证:基于 LINUX DO Connect (OAuth)
  • -
  • 认证方式:账户积分消耗认证
  • -
  • 手续费:动态费率,由服务方承担
  • -
  • 争议处理:支持服务方与消费方的双方争议处理
  • +
  • 架构底座:Go (Gin + GORM + Redis + Asynq) 后端 + React (Next.js 16 + Tailwind CSS 4 + Shadcn UI) 前端
  • +
  • 认证体系:支持本地常规账号密码注册登录 + 第三方自定义 OIDC (OAuth2) 认证源绑定
  • +
  • 访问令牌:提供开发者个人 AccessToken (API Key),用于通过 Http Header 鉴权直接调用系统 API
  • +
  • 可观测性:集成 Zap 结构化日志与 OpenTelemetry 全链路 Tracing 追踪
) }, { - value: "roles", - title: "2. 角色说明", + value: "auth-security", + title: "2. 身份认证与安全设置", content: (
-
    -
  • 服务方:最终积分流转的转入方
  • -
  • 消费方:最终积分流转的转出方
  • -
  • 认证平台:LINUX DO Credit 系统本身
  • +

    平台采用混合式身份认证,满足不同的部署和业务场景需求:

    +

    2.1 常规账号密码认证

    +
      +
    • 自主注册与密码登录:支持普通用户通过用户名及密码直接进行注册与会话建立,密码在后端采用 bcrypt 高强度加盐哈希存储。
    • +
    • 系统开关控制:管理员可在后台配置动态开关,随时禁用自主密码注册或密码登录,以转为纯第三方认证模式。
    -
- ) - }, - { - value: "integration", - title: "3. 接入积分服务", - content: ( -
-

3.1 使用 API 接口

-
    -
  1. - 创建应用 -
      -
    • 前往 控制面板
    • -
    • 点击顶部右侧 创建应用 按钮
    • -
    • 填写必要信息:应用名称、应用主页、回调地址、通知地址
    • -
    -
  2. -
  3. - 获取 API 凭证 -
      -
    • 在集市中心顶部右侧选择器中选择您的应用
    • -
    • 在 API 配置 面板中获取: -
        -
      • Client ID:客户端ID,用于标识您的身份
      • -
      • Client Secret:客户端密钥,用于签名验证(请妥善保管,切勿泄露)
      • -
      -
    • -
    -
  4. -
  5. - 使用 API 接口 -
      -
    • 使用 API 接口创建积分流转服务
    • -
    • 参考文档:API 接口文档
    • -
    -
  6. + +

    2.2 第三方 OIDC 认证源

    +

    用户可以在个人资料页面关联绑定外部授权账户:

    +
      +
    1. 进入 设置 / 个人资料 页面。
    2. +
    3. 在“第三方账号绑定”栏目下查看当前绑定的账号,或点击未绑定的可用 OIDC 认证源直接触发 OAuth2 绑定流。
    4. +
    5. 绑定成功后,用户在登录界面可直接点击 OIDC 登录按钮实现快捷跳转。
    - -

    3.2 使用在线服务

    -
      -
    • 适用场景:无代码开发基础,或只用于简单的积分服务。
    • -
    • 操作步骤: -
        -
      1. 前往 控制面板 创建应用,获取 API 凭证
      2. -
      3. 选择应用,点击 在线收款 功能
      4. -
      5. 创建在线积分服务
      6. -
      7. 获取唯一积分服务链接
      8. -
      9. 发送给您所服务的客户使用
      10. -
      -
    • -
    - -

    3.3 快速集成 New API

    -
      -
    • 适用场景:New API 站点,LINUX DO Credit 兼容 EasyPay 协议,公益站站长可直接集成。
    • -
    • 操作步骤: -
        -
      1. - 前往 控制面板,点击 创建应用,填写 New API 站点信息: -
        - - - - 字段 - 值 - - - - - 应用名称 - 您的应用名称 - - - 应用主页 - https://{"{您的 New API 域名}"} - - - 回调地址 - https://{"{您的 New API 域名}"}/console/log - - - 通知地址 - https://{"{您的 New API 域名}"}/api/user/epay/notify - - - -
        -
      2. -
      3. 前往 New API 站点的系统设置,找到 支付设置。
      4. -
      5. - 配置 LINUX DO Credit 平台参数: -
        - - - - 参数 - 值 - - - - - 支付地址 - https://credit.linux.do/epay/pay - - - 易支付商户ID - 您的 Client ID - - - 易支付商户密钥 - 您的 Client Secret - - - 回调地址 - https://{"{您的 New API 域名}"} - - - -
        -
      6. -
      7. - 配置充值方式(JSON 格式): -
        - -
        -
      8. -
      -
    • -
), children: [ - { value: "3-1-api", title: "3.1 使用 API 接口" }, - { value: "3-2-online", title: "3.2 使用在线服务" }, - { value: "3-3-new-api", title: "3.3 快速集成 New API" }, + { value: "2-1-login", title: "2.1 常规账号密码认证" }, + { value: "2-2-oidc", title: "2.2 第三方 OIDC 认证源" }, ] }, { - value: "usage", - title: "4. 使用 LINUX DO Credit 积分", + value: "access-token", + title: "3. 个人访问令牌 (AccessToken) 接口对接", content: (
-

您可以在任意支持 LINUX DO Credit 的平台下使用积分。在其他平台点击使用 LINUX DO Credit 积分时,会自动跳转到 LINUX DO Credit 的积分流转服务页面,您只需要确认积分流转信息无误,并选择使用 LINUX DO Credit 进行账户认证,即可完成整个交易服务。

-
- ) - }, - { - value: "fees", - title: "5. 服务(手续)费", - content: ( -
-

5.1 规则说明

-

为了更好的维持 LINUX DO Credit 平台的积分服务机制,保证社区积分的生态可持续发展,我们会按照规范进行不同程度的服务(手续)费用收取。

+

为便于开发者或第三方工具直接调用系统 API,平台提供个人访问令牌管理功能。

+

3.1 令牌生成与存储规范

    -
  • 承担方:服务(手续)费默认由服务方承担
  • -
  • 消费方使用:不会产生额外费用
  • -
  • 服务方实收:订单金额 - 服务(手续)费
  • +
  • 一次性明文展示:创建令牌时生成的随机明文 Token 值 (形如 at_xxx) 仅会在弹窗中展示一次。请立即复制保存,关闭弹窗后系统将无法重新获取。
  • +
  • 安全哈希存储:数据库仅存储 Token 的 SHA-256 哈希指纹,即使数据库泄漏,攻击者也无法通过摘要逆向恢复令牌原文。
-
-

计算公式:

+ +

3.2 携带 Header 进行 API 调用

+

您可以凭借保存的明文 Token 随时调用系统开放接口,系统认证支持以下两种 Http 请求头携带方式之一:

+
+

方式一:Authorization Bearer 头

+
+
+

方式二:X-Access-Token 自定义头

+
- -

5.2 动态费率

-

费率并非固定不变,会根据以下因素动态调整:

-
    -
  • 服务方平台等级
  • -
  • 服务方平台积分
  • -
  • LINUX DO Credit 平台活动
  • -
), children: [ - { value: "5-1-rules", title: "5.1 规则说明" }, - { value: "5-2-dynamic", title: "5.2 动态费率" }, + { value: "3-1-generation", title: "3.1 令牌生成与存储规范" }, + { value: "3-2-usage", title: "3.2 携带 Header 进行 API 调用" }, ] }, { - value: "dispute", - title: "6. 争议处理", + value: "config-system", + title: "4. 动态系统配置项", content: (
-

为了保障服务方与消费方的合法权益,当积分服务出现纠纷时,可使用争议功能。

+

平台内置了完备的 KV 配置管理模块,允许管理员动态调整系统运行状态:

    -
  • 作为服务方,您需要及时响应消费方的争议请求: -
      -
    1. 在集市中心或通知中查看到 待处理的争议
    2. -
    3. 查看消费方理由,选择操作: -
        -
      • 同意:认可消费方诉求,积分原路退回给消费方
      • -
      • 拒绝:如果您认为已履约,请提交相关证据
      • -
      -
    4. -
    -
  • -
-
-

- 重要:建议服务方与消费方优先沟通解决。长时间未处理的争议会由 LINUX DO Credit 平台介入仲裁,这可能会影响您的服务方信誉。 -

-
-
- ) - }, - { - value: "community-balance", - title: "7. 社区积分", - content: ( -
-

您的 LINUX DO Credit 平台基础积分主要由 社区积分 (Community Balance) 划转而来。

-
    -
  • 基本获取方式:通过在 LINUX DO 社区的活跃行为获得: - -
  • -
  • 划转规则: -
      -
    • 划转时间:社区积分每日凌晨 00:00 自动划转至可用余额
    • -
    • 限制说明:划转前不可用于任何积分服务
    • -
    • 服务费用:目前不收取任何划转 服务费
    • +
    • 缓存读取加速:配置加载基于 GORM 读取数据库,并辅以 Redis Hash 结构进行多层缓存加速,大幅降低配置查询耗时。
    • +
    • 核心系统配置项说明: +
        +
      • site_name:平台展示名称
      • +
      • password_login_enabled:密码登录开关
      • +
      • registration_enabled:用户自主注册开关
      • +
      • max_api_keys_per_user:普通用户创建令牌的最大数限制 (默认5)
    @@ -283,46 +110,28 @@ export const howToUseSections: PolicySection[] = [ ) }, { - value: "settings", - title: "8. 账户设置", + value: "worker-scheduler", + title: "5. 异步任务与定时调度", content: (
    -

    您可以在 设置 (Settings) 页面管理您的账户信息。

    -

    功能列表

    -
      -
    • 个人资料:查看当前的账户信息和会员等级
    • -
    • 安全设置:修改认证密码
    • -
    • 外观设置:切换页面主题、界面外观
    • +

      项目借助 Cobra 实现多命令入口分发,通过独立部署 Worker 服务解耦复杂计算或高延迟IO:

      +
        +
      • 定时任务派发 (CMD scheduler):负责按 Cron 表达式配置,定时将待执行任务推送到 Redis 队列中。
      • +
      • 多优先级 Worker (CMD worker):基于 Asynq 驱动,按优先级拉取任务并并发调度执行(如定时清理临时上传目录、同步外部系统日志等)。
    ) }, { - value: "scripts", - title: "9. 辅助脚本", + value: "tracing-metrics", + title: "6. 链路追踪与结构化日志", content: (
    -

    为了方便用户随时查看当前的实时积分收入,我们提供了开源的 Userscript 脚本。

    +

    为了保障分布式微服务架构下的可观测性,平台接入了高级监控组件:

      -
    • 功能:在 LINUX DO 显示实时积分收入,支持拖拽,不影响界面。
    • -
    • 获取:仅需安装 Tampermonkey 插件即可使用。
    • -
    • 安装: - - 「LINUX DO Credit」实时积分收入脚本 - - -
    • +
    • OpenTelemetry Tracing:自动传递 Tracing 上下文,所有经由 Gin 中间件、外部请求或 GORM 数据库的事务操作都将带有全局唯一的 Span,用于排查链路耗时或调用异常。
    • +
    • Zap 结构化日志:将后端控制台或日志输出格式统一规范化为 JSON,方便与 ELK、Loki 等日志收集分析工具无缝对接。
    -
    -

    - 注:脚本完全开源且安全,仅通过官方 API 获取公开数据,不涉及任何敏感权限。 -

    -
    ) } diff --git a/frontend/components/common/docs/privacy.tsx b/frontend/components/common/docs/privacy.tsx index 3c98e992..7f3198a3 100644 --- a/frontend/components/common/docs/privacy.tsx +++ b/frontend/components/common/docs/privacy.tsx @@ -1,4 +1,4 @@ -import { type PolicySection } from "./types" +import {type PolicySection} from "./types" /** * ------------------------------------------------------------------ @@ -15,15 +15,11 @@ export const privacySections: PolicySection[] = [
    1.1 身份鉴权信息: -

    当您通过 LINUX DO Connect 登录时,我们会获取您的社区 OpenID(唯一标识符)、加密后的用户名及头像 URL。我们不收集您的手机号、真实姓名或身份证件信息。

    +

    当您通过本地账号注册或绑定第三方 OIDC 认证源登录时,我们会收集您的用户名、关联的邮箱、加密后的密码哈希指纹及关联的第三方 OpenID 标识符。我们不强制要求绑定手机号、身份证件或任何真实社会信用实体信息。

    - 1.2 服务日志信息: -

    为保障系统运行安全及满足法律合规要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、API 调用记录、User-Agent(浏览器/设备类型)。

    -
    -
    - 1.3 交易与资产信息: -

    若您使用支付功能,我们将记录您的商户订单号、交易金额、交易时间、交易状态摘要。这些信息是账务核对的必要依据。

    + 1.2 服务与接口日志信息: +

    为保障系统运行安全及满足安全审计要求,我们会自动收集您的操作日志,包括 IP 地址、访问日期和时间、个人访问令牌 API 调用历史记录、User-Agent(浏览器/设备/请求工具类型)。

@@ -36,10 +32,9 @@ export const privacySections: PolicySection[] = [

我们深知数据安全的重要性,并采取业界领先的技术措施保护您的数据:

    -
  • 存储地点:依照法规要求,我们收集和产生的用户个人信息,存储在独立的服务器上。我们不会将您的数据传输至境外管辖区。
  • -
  • 加密技术:敏感数据(如 支付密码)在数据库中均采用高强度加密算法存储。数据传输全链路采用 SSL/TLS 1.3 协议进行加密,防止网络嗅探。
  • -
  • 隔离机制:本平台数据与外部网络物理隔离,且独立于 LINUX DO 社区论坛主数据库,确保单一系统故障不会波及全局数据安全。
  • -
  • 访问控制:我们实行严格的最小权限原则(Least Privilege),仅有核心运维人员经授权后方可访问必要的维护数据,且所有操作均有审计日志留存。
  • +
  • 存储安全:用户数据独立存储于专用的云数据库或本地容器化持久层中,仅供授权系统应用挂载读取。
  • +
  • 加密技术:敏感的密码指纹和个人访问令牌哈希在数据库中均采用高强度算法加密存储。API 数据传输链路强制使用 SSL/TLS 进行安全加密。
  • +
  • 访问控制:我们实行严格的最小权限原则(Least Privilege),平台数据不会对外共享,所有系统级内部运维操作均有审计日志可查。
), @@ -51,12 +46,11 @@ export const privacySections: PolicySection[] = [

我们收集的信息将仅用于以下目的:

    -
  • 身份识别:用于确认您的社区身份,展示您的个人中心数据。
  • -
  • 业务功能:处理您的支付指令、API 请求、回调通知及账单生成。
  • -
  • 安全风控:利用 IP 及行为日志进行反作弊、反欺诈分析,识别恶意攻击行为,保护平台及其他用户的安全。
  • -
  • 客户支持:在您发起工单或申诉时,查询相关日志以协助您解决问题。
  • +
  • 身份识别:用于确认您的注册身份,展示您的个人中心及设置页面数据。
  • +
  • 业务功能:用于识别并处理您的个人访问令牌 API 鉴权指令。
  • +
  • 安全风控:利用 IP 及行为日志进行接口反作弊、反暴力破解分析,保障后台服务稳定性。
-

禁止用途:我们承诺绝不利用您的数据进行用户画像分析、个性化广告推送或商业营销。

+

禁止用途:我们承诺绝不将您的数据出售给第三方,亦不会向任何机构提供任何用户画像分析或广告推送服务。

), }, @@ -65,12 +59,12 @@ export const privacySections: PolicySection[] = [ title: "4. 信息共享与对外披露", content: (
-

4.1 共享原则:我们坚持数据零共享策略。除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、支付宝、微信、银行、广告商)共享您的个人信息:

+

4.1 共享原则:除以下极端情况外,我们不会向任何第三方(包括且不限于关联公司、商业合作伙伴)共享您的个人信息:

  • 事先获得您的明确授权或同意;
  • 根据适用的法律法规、法律程序的要求、强制性的行政或司法要求所必须的情况下进行提供。
-

4.2 转让与公开披露:我们不会将您的个人信息转让给任何公司、组织和个人。我们仅在法律法规强制要求,或为了保护平台及用户与公众的人身财产安全免受侵害时,才会公开披露您的信息。

+

4.2 转让与公开披露:我们不会将您的个人信息转让给任何公司、组织和个人,亦不进行任何公开商业披露。

), }, @@ -82,16 +76,12 @@ export const privacySections: PolicySection[] = [

依照《中华人民共和国个人信息保护法》,您对您的个人信息享有完整的控制权:

- 5.1 查阅与复制权: -

您可以随时登录开发者后台,查阅您的概览信息、API Key 状态及历史交易账单。您可以通过“导出账单”功能获取您的数据副本。

+ 5.1 查阅与管理权: +

您可以随时登录本平台,查阅您的基础个人信息,生成、轮换或撤销您的 AccessToken (API 密钥)。

- 5.2 删除与遗忘权: -

若您决定停止使用本服务,在结清所有应付费用及余额后,您可以申请注销账户。注销后,我们将立即删除您的所有敏感信息或进行匿名化处理,法律法规规定需保留的日志除外。

-
-
- 5.3 纠正与更正权: -

若您发现您的信息有误,您有权要求我们更正或补充。您可以在设置页面直接修改您的信息,或通过客服提交工单。

+ 5.2 删除与注销权: +

若您决定停止使用本服务,您可以申请注销账户。注销后,我们将立即从活跃存储媒介中删除您的所有敏感信息,法律法规要求留存的安全日志除外。

@@ -102,8 +92,8 @@ export const privacySections: PolicySection[] = [ title: "6. 政策更新与通知", content: (
-

随着业务的发展或法律法规的变动,我们可能会适时修订本《隐私政策》。

-

当条款发生重大变更时(例如收集范围扩大、使用目的改变),我们会通过站内信、公告或弹窗等显著方式通知您。若您在政策更新后继续使用本服务,即表示您同意接受更新后的隐私政策约束。

+

随着业务的发展或法律法规的变动,本《隐私政策》条款可能发生变更。

+

当条款发生重大变更时,我们会以显著方式(如平台公告、站内弹窗等)予以通知。如果您继续使用本服务,即表示您同意接受修订后的政策约束。

), }, diff --git a/frontend/components/common/docs/terms.tsx b/frontend/components/common/docs/terms.tsx index 87cd4e5f..b57fc3f6 100644 --- a/frontend/components/common/docs/terms.tsx +++ b/frontend/components/common/docs/terms.tsx @@ -1,6 +1,6 @@ -import { type PolicySection } from "./types" +import {type PolicySection} from "./types" -export const TERMS_LAST_UPDATED = "2025-12-22" +export const TERMS_LAST_UPDATED = "2026-06-07" /** * ------------------------------------------------------------------ @@ -13,8 +13,8 @@ export const termsSections: PolicySection[] = [ title: "1. 缔约申明与服务综述", content: (
-

1.1 缔约主体:本《服务协议》(以下简称“本协议”)是您(以下亦称“社区用户”、“开发者”、“消费方”或“服务方”)与 LINUX DO Credit 平台运营团队(以下简称“平台”、“我们”)之间关于使用平台服务所订立的具有法律约束力的契约。

-

1.2 审慎阅读:请您务必审慎阅读、充分理解各条款内容,特别是免除或者限制责任的条款、争议解决和法律适用条款。各免责或限责条款将以粗体标识,您应重点阅读。如您不同意本协议的任何内容,请立即停止注册或使用本服务。

+

1.1 缔约主体:本《服务协议》(以下简称“本协议”)是您(以下称“用户”或“开发者”)与本通用开发脚手架平台(以下简称“本系统”或“平台”)运营维护方之间关于使用本系统所订立的契约。

+

1.2 审慎阅读:本系统作为一个通用的、面向二次开发的全栈软件底座,旨在为用户提供基础的注册、会话、OIDC 接入、API Key 令牌鉴权及后台管理服务。若您使用本系统,请务必仔细阅读本协议各条款。

1.3 协议构成:本协议内容包括协议正文及所有我们已经发布或将来可能发布的各类规则、声明、说明。所有规则为本协议不可分割的组成部分,与协议正文具有同等法律效力。

), @@ -24,16 +24,13 @@ export const termsSections: PolicySection[] = [ title: "2. 服务定义与性质界定", content: (
-

2.1 社区技术服务:LINUX DO Credit 是基于 LINUX DO 社区生态构建的独立价值交换协议与技术系统。我们仅提供 API 接口调用、数据路由、账单管理等纯技术服务。

-

2.2 非金融机构申明:

+

2.1 纯技术研发脚手架:本平台是一个开源/闭源授权的技术二次开发底座。我们仅提供用户注册、API 调用、安全配置维护等纯软件技术服务。

+

2.2 非金融机构与无承兑申明:

    -
  • 非银行机构:我们不是商业银行、持牌支付机构(如支付宝、微信支付、银联)或清算机构。
  • -
  • 不提供资金沉淀:平台不设立资金池,不提供真实法币存取款、转账汇款或支付结算服务。所有涉及资金流转的行为均发生于社区用户与社区支付渠道之间,不涉及真实货币。
  • -
  • 不提供金融服务:平台不提供任何金融服务,包括但不限于贷款、融资、投资、理财、保险等金融服务。
  • -
  • 不提供积分兑现:平台不提供任何积分兑换服务,包括但不限于积分兑换为真实货币、积分兑换为实物商品、积分兑换为服务等。
  • -
  • 不提供真实货币交易:平台不提供任何真实货币交易服务,包括但不限于真实货币交易为积分、真实货币交易为虚拟资产、真实货币交易为服务等。
  • +
  • 非持牌金融或支付机构:本系统不是银行、商户收单或清算结算机构。
  • +
  • 不提供资金管理:系统无真实法币、加密货币或商业代金券充值、存储与兑现功能。若在二开中加入了积分等属性,其最终性质也应限制于虚拟软件积分。
  • +
  • 责任自担:关于用户对本系统进行二次开发并应用于其他生产环境产生的任何业务行为,由二开部署运营主体承担全部合规责任。
-

2.3 服务限定:本平台建议用于仅支持虚拟商品、软件授权、技术咨询、会员订阅等无实物交付的场景。关于实物电商、物流发货或涉及线下履约的商业场景造成的任何后果我们概不负责,请妥善保管好自己的个人财产,谨防上当受骗。

), }, @@ -42,13 +39,12 @@ export const termsSections: PolicySection[] = [ title: "3. 账号注册与使用规范", content: (
-

3.1 账号体系:本平台采用 LINUX DO Connect (OAuth) 授权登录体系。您必须拥有合法、有效的 LINUX DO 社区账号方可使用本服务。您的平台账号权益(包括但不限于信誉分、等级)与社区账号严格绑定。

-

3.2 匿名性与真实性:

+

3.1 账号体系:用户可通过本系统的前端注册表单自助创建账户,或通过系统管理员配置并启用的自定义第三方 OIDC 认证源进行登录关联。

+

3.2 密码及令牌安全责任:

    -
  • 无需实名:我们尊重您的隐私,不强制要求您提供居民身份证、护照或营业执照进行实名认证。
  • -
  • 操作真实性:您承诺注册和使用的账号是您本人操作。严禁恶意注册、挂机脚本、自动化程序注册等破坏平台公平性的行为。
  • +
  • 密码安全:您应妥善保管您账户的登录密码。
  • +
  • API Token 安全:个人生成的 AccessToken (API Key) 代表您账户的完整调用权限。因您保管不善导致 Token 泄漏而造成的一切数据丢失或系统损失,均由您自行承担。
-

3.3 账号安全责任:您应妥善保管您账号的支付密码、Client ID 和 Client Secret。因您保管不善可能导致账号被他人非法使用、资金损失或数据泄露的责任,由您自行承担。如发现账号异常,请立即通知我们进行冻结。

), }, @@ -57,58 +53,43 @@ export const termsSections: PolicySection[] = [ title: "4. 用户行为准则(负面清单)", content: (
-

您在使用本服务时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):

+

您在使用本系统时,必须严格遵守《中华人民共和国网络安全法》、《计算机信息网络国际联网安全保护管理办法》等法律法规。严禁利用本平台从事以下活动(“红线条款”):

  • 危害国家安全:反对宪法所确定的基本原则、危害国家安全、泄露国家秘密、颠覆国家政权、破坏国家统一的;
  • 非法信息服务:黑客攻击工具、DDoS 攻击服务、服务器爆破等其他非法信息服务平台;
  • 黄赌毒关联:制作、复制、发布、传播淫秽、色情、赌博、暴力、凶杀、恐怖或者教唆犯罪的;
  • 侵犯知识产权:销售盗版软件、盗版影视资源、非法游戏外挂、私服、黑号、社工库数据等;
  • -
  • 欺诈与虚假:进行电信诈骗、金融诈骗、传销、虚假广告虚假交易等;
  • 其他违法信息:涉及散布谣言、宣扬邪教/封建迷信、侮辱/诽谤他人、侵害他人合法权益的。
-

违约处理:一旦发现您违反上述规定,平台有权不经通知立即永久封禁您的账号、拦截所有 API 请求、冻结账户内所有关联价值,并依法向公安机关、网安部门移交相关线索。

-
- ), - }, - { - value: "virtual-assets", - title: "5. 虚拟资产与交易规则", - content: ( -
-

5.1 资产性质:平台内流转的“余额”、“积分”等均为社区虚拟积分,仅代表您在社区生态内的活跃度或贡献值。它们不具有任何货币属性,严禁兑换为法定货币,也不可用于任何非平台许可的商业交易。

-

5.2 交易不可逆:鉴于网络技术的实时性,一旦积分消耗或划转指令被执行,该操作即不可撤销。请您在确认相关积分活动前,务必仔细核对服务方信息。

-

5.3 规则说明:为营造良好的社区积分环境,平台有权收取一定的服务费(以积分为结算单位)进行调控,具体规则以控制台公示为准。平台保留根据社区运营状况调整积分规则的权利。

+

处理规则:一旦发现您违反上述规定,平台运维主体有权不经通知立即永久封禁您的账号、拦截所有 AccessToken 请求,并依法向公安机关、网安部门移交相关线索。

), }, { value: "liability-limitation", - title: "6. 免责声明与不可抗力", + title: "5. 免责声明与不可抗力", content: (
-

6.1 基础免责:本平台服务按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的要求,也不保证服务不会中断,对服务的及时性、安全性、准确性都不作担保。

-

6.2 不可抗力:对于因以下原因导致的服务中断、数据丢失或账号损失,平台不承担赔偿责任:

+

5.1 基础免责:本开发底座按“现状”(As-Is)及“现有”(As-Available)状态提供。我们不保证服务一定能满足您的特定开发需求,对服务的及时性、安全性、准确性都不作额外担保。

+

5.2 技术服务中断:对于因以下不可抗力原因导致的服务中断、数据丢失或账号损坏,平台不承担赔偿责任:

  • 自然灾害(台风、地震、海啸、洪水等);
  • -
  • 政府行为、法律法规或政策调整、行政命令;
  • -
  • 电信部门技术调整、通讯线路中断、海底光缆故障;
  • -
  • 黑客攻击、计算机病毒侵入或发作、技术性故障;
  • -
  • 社区维护、系统升级(我们将尽可能提前公告)。
  • +
  • 政府行为、网络安全法律法规调整或行政命令;
  • +
  • 电信部门线路技术故障、机房海底光缆损毁;
  • +
  • 黑客入侵、勒索病毒感染导致的数据损毁或宕机。
-

6.3 责任上限:在任何情况下,平台对您所承担的违约赔偿责任总额不超过您在违约行为发生前 1 个月内向平台支付的费用总额。

), }, { value: "governing-law", - title: "7. 法律适用与争议解决", + title: "6. 法律适用与争议解决", content: (
-

7.1 法律适用:本协议的订立、执行、解释及争议的解决均适用中华人民共和国法律(不包括港澳台地区法律及冲突法)。

-

7.2 争议解决:若您和平台发生任何争议或纠纷,首先应友好协商解决;协商不成的,您同意将纠纷或争议提交至平台运营团队所在地有管辖权的人民法院管辖。

-

7.3 协议变更:我们有权根据法律法规变化或业务发展需要修改本协议。变更后的协议将在平台公示,自公示之日起生效。若您继续使用服务,视为您已接受修订后的协议。

+

6.1 法律适用:本协议的订立、执行、解释及争议的解决均适用中华人民共和国法律(不包括港澳台地区冲突法)。

+

6.2 争议解决:若发生任何争议或纠纷,首先应友好协商解决;协商不成的,应提交至本系统部署或运营方所在地有管辖权的人民法院管辖。

), }, diff --git a/frontend/components/common/settings/access-token.tsx b/frontend/components/common/settings/access-token.tsx new file mode 100644 index 00000000..18638263 --- /dev/null +++ b/frontend/components/common/settings/access-token.tsx @@ -0,0 +1,398 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" +import {motion} from "motion/react" +import {AlertTriangle, Check, Copy, Info, Key, Loader2, Plus, RefreshCw, Trash2} 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 { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import {UserService} from "@/lib/services" +import type {CreateTokenResponse} from "@/lib/services/user" +import {toast} from "sonner" + +export function AccessTokenMain() { + const queryClient = useQueryClient() + const [createDialogOpen, setCreateDialogOpen] = React.useState(false) + const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [tokenName, setTokenName] = React.useState("") + const [copiedId, setCopiedId] = React.useState(null) + const [newCreatedToken, setNewCreatedToken] = React.useState(null) + + // 获取 Token 列表 + const accessTokensQuery = useQuery({ + queryKey: ["user", "access-tokens"], + queryFn: () => UserService.getAccessTokens(), + }) + + // 创建 Token + const createTokenMutation = useMutation({ + mutationFn: (name: string) => UserService.createAccessToken(name), + onSuccess: (data) => { + setNewCreatedToken(data) + setTokenName("") + setCreateDialogOpen(false) + setViewDialogOpen(true) + void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] }) + toast.success("访问令牌创建成功") + }, + onError: (error: Error) => { + toast.error(error.message || "创建访问令牌失败") + }, + }) + + // 删除 Token + const deleteTokenMutation = useMutation({ + mutationFn: (id: number) => UserService.deleteAccessToken(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] }) + toast.success("访问令牌已撤销") + }, + onError: (error: Error) => { + toast.error(error.message || "删除访问令牌失败") + }, + }) + + // 轮换 Token + const rotateTokenMutation = useMutation({ + mutationFn: (id: number) => UserService.rotateAccessToken(id), + onSuccess: (data) => { + setNewCreatedToken(data) + setViewDialogOpen(true) + void queryClient.invalidateQueries({ queryKey: ["user", "access-tokens"] }) + toast.success("访问令牌轮换成功,旧密钥已失效") + }, + onError: (error: Error) => { + toast.error(error.message || "轮换访问令牌失败") + }, + }) + + const handleCreateToken = (e: React.FormEvent) => { + e.preventDefault() + if (!tokenName.trim()) { + toast.error("请输入令牌名称") + return + } + createTokenMutation.mutate(tokenName.trim()) + } + + const handleDeleteToken = (id: number, name: string) => { + if (window.confirm(`确定要删除并撤销令牌「${name}」吗?删除后此令牌将立即失效且不可恢复。`)) { + deleteTokenMutation.mutate(id) + } + } + + const handleRotateToken = (id: number, name: string) => { + if (window.confirm(`确定要轮换令牌「${name}」的密钥吗?轮换后系统将生成全新密钥,原令牌密钥将立即失效。`)) { + rotateTokenMutation.mutate(id) + } + } + + const handleCopyText = async (text: string, id: number) => { + try { + await navigator.clipboard.writeText(text) + setCopiedId(id) + toast.success("复制成功") + setTimeout(() => setCopiedId(null), 2000) + } catch { + toast.error("复制失败") + } + } + + const formatDate = (dateStr?: string) => { + if (!dateStr) return "未使用" + return new Date(dateStr).toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) + } + + return ( + +
+ + + + + 设置 + + + + + 访问令牌 + + + +
+ +
+
+
+ +
+
+

个人访问令牌 (AccessToken)

+

管理您的 API 访问密钥,用于开发或第三方工具直接调用系统 API

+
+
+ +
+ + {/* 安全警告提示 */} +
+ +
+ 安全提示: +

+ 访问令牌具有您账户的完整接口调用权限。为了您的账户与资产安全,请切勿通过任何代码库提交、即时通讯工具或公共媒介泄露此令牌。推荐按需创建,不使用时及时撤销。 +

+
+
+ + + + 活动令牌列表 + 当前可用的所有访问令牌 + + + {accessTokensQuery.isPending ? ( +
+ +
+ ) : (accessTokensQuery.data ?? []).length > 0 ? ( +
+ {(accessTokensQuery.data ?? []).map((token) => ( +
+
+
+ {token.name} +
+
+
+ {token.masked_token} +
+
+ 创建于: {formatDate(token.created_at)} + 最后使用: {formatDate(token.last_used_at)} +
+
+
+
+ + + +
+
+ ))} +
+ ) : ( +
+ + 您当前暂无生成任何访问令牌 + +
+ )} +
+
+ + {/* 创建令牌 Dialog */} + + +
+ + 生成新令牌 + + 请为新的访问令牌设置一个易于识别的名称,以便将来管理。 + + +
+
+ + setTokenName(e.target.value)} + disabled={createTokenMutation.isPending} + className="rounded-xl border border-dashed focus:border-indigo-500 focus:ring-0 focus-visible:ring-0" + /> +
+
+ + + + +
+
+
+ + {/* 明文 Token 显示 Dialog (仅显示一次) */} + { + if (!open) { + setNewCreatedToken(null) + setViewDialogOpen(false) + } + }}> + + + + + 令牌密钥已就绪 + + + 这是您唯一一次能够查看此访问令牌明文密钥的机会。请立即将其复制并安全地保存。 + + + + {newCreatedToken && ( +
+ {/* 明文 Token 文本框 */} +
+ + {newCreatedToken.token} + + +
+ + {/* 强提示 */} +
+ +
+ 重要提示: +

+ 为了系统安全性,数据库中仅存储令牌的 Hash 摘要值,系统本身无法为您找回此明文密钥。离开此窗口后,您将再也无法查看到它的明文值。 +

+
+
+
+ )} + + + + +
+
+
+ ) +} diff --git a/frontend/components/common/settings/files.tsx b/frontend/components/common/settings/files.tsx new file mode 100644 index 00000000..c80d7da6 --- /dev/null +++ b/frontend/components/common/settings/files.tsx @@ -0,0 +1,408 @@ +"use client" + +import * as React from "react" +import Link from "next/link" +import {useMutation, useQuery, useQueryClient} from "@tanstack/react-query" +import {AnimatePresence, motion} from "motion/react" +import { + Download, + FileArchive, + FileAudio, + FileImage, + FileText, + FileVideo, + Folder, + Loader2, + Search, + Trash2, + Upload, + X, +} from "lucide-react" +import {toast} from "sonner" + +import {Button} from "@/components/ui/button" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbLink, + BreadcrumbList, + BreadcrumbPage, + BreadcrumbSeparator, +} from "@/components/ui/breadcrumb" +import {Input} from "@/components/ui/input" +import {Badge} from "@/components/ui/badge" +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog" +import {formatFileSize, UploadService} from "@/lib/services/upload/upload.service" +import type {Upload as UploadRecord} from "@/lib/services/upload/types" + +/* ─── 工具函数 ─────────────────────────────────────────── */ + +function getFileIcon(mimeType: string, className = "size-8") { + if (mimeType.startsWith("image/")) return + if (mimeType.startsWith("video/")) return + if (mimeType.startsWith("audio/")) return + if (mimeType.includes("zip") || mimeType.includes("tar") || mimeType.includes("gzip")) + return + return +} + +function formatDate(dateStr: string) { + return new Date(dateStr).toLocaleString("zh-CN", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }) +} + +/* ─── 文件管理主组件 ────────────────────────────────────── */ + +export function FilesMain() { + const queryClient = useQueryClient() + const [keyword, setKeyword] = React.useState("") + const [debouncedKeyword, setDebouncedKeyword] = React.useState("") + const [selectedIds, setSelectedIds] = React.useState>(new Set()) + const [deleteTarget, setDeleteTarget] = React.useState(null) + const [page, setPage] = React.useState(1) + const pageSize = 24 + + // 搜索防抖 + React.useEffect(() => { + const timer = setTimeout(() => { + setDebouncedKeyword(keyword) + setPage(1) + }, 400) + return () => clearTimeout(timer) + }, [keyword]) + + // 文件列表查询 + const listQuery = useQuery({ + queryKey: ["files", "my", page, pageSize, debouncedKeyword], + queryFn: () => UploadService.listMyFiles(page, pageSize, debouncedKeyword || undefined), + }) + + const files = listQuery.data?.items ?? [] + const total = listQuery.data?.total ?? 0 + const totalPages = Math.ceil(total / pageSize) + + // 删除单文件 + const deleteMutation = useMutation({ + mutationFn: (id: string) => UploadService.deleteFile(id), + onSuccess: () => { + void queryClient.invalidateQueries({ queryKey: ["files", "my"] }) + toast.success("文件已删除") + setDeleteTarget(null) + }, + onError: (err: Error) => toast.error(err.message || "删除失败"), + }) + + // 批量 ZIP 下载 + const batchDownloadMutation = useMutation({ + mutationFn: (ids: string[]) => UploadService.batchDownload(ids), + onSuccess: (blob) => { + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = "batch_download.zip" + a.click() + URL.revokeObjectURL(url) + toast.success("批量下载已开始") + }, + onError: () => toast.error("批量下载失败"), + }) + + const toggleSelect = (id: string) => { + setSelectedIds((prev) => { + const next = new Set(prev) + next.has(id) ? next.delete(id) : next.add(id) + return next + }) + } + + const clearSelection = () => setSelectedIds(new Set()) + + const selectAll = () => setSelectedIds(new Set(files.map((f) => f.id))) + + const handleDownload = (file: UploadRecord) => { + const url = UploadService.getDownloadUrl(file.id) + const a = document.createElement("a") + a.href = url + a.download = file.file_name + a.click() + } + + return ( + + {/* Breadcrumb */} +
+ + + + + 设置 + + + + + 文件管理 + + + +
+ + {/* 头部 */} +
+
+
+ +
+
+

+ 我的文件 +

+

+ 管理您上传的所有文件,支持下载和批量操作 +

+
+
+ + {/* 操作按钮区 */} +
+ + {selectedIds.size > 0 && ( + + + 已选 {selectedIds.size} 个 + + + + + )} + +
+
+ + {/* 搜索栏 */} +
+
+ + setKeyword(e.target.value)} + /> + {keyword && ( + + )} +
+ {files.length > 0 && ( + + )} + {total > 0 && ( + 共 {total} 个文件 + )} +
+ + {/* 文件网格 */} + {listQuery.isPending ? ( +
+ +
+ ) : files.length === 0 ? ( +
+ +

+ {debouncedKeyword ? "没有匹配的文件" : "您还没有上传任何文件"} +

+
+ ) : ( +
+ {files.map((file, idx) => { + const isSelected = selectedIds.has(file.id) + return ( + toggleSelect(file.id)} + > + {/* 选中指示 */} + {isSelected && ( +
+ + + +
+ )} + + {/* 文件图标 / 图片预览 */} +
+ {file.mime_type.startsWith("image/") ? ( + // eslint-disable-next-line @next/next/no-img-element + {file.file_name} { + ;(e.currentTarget as HTMLImageElement).style.display = "none" + }} + /> + ) : ( + getFileIcon(file.mime_type) + )} +
+ + {/* 文件名 */} +

+ {file.file_name} +

+ + {/* 元数据 */} +
+

{formatFileSize(file.file_size)}

+

{formatDate(file.created_at)}

+
+ + {/* Hover 操作按钮 */} +
e.stopPropagation()} + > + + +
+
+ ) + })} +
+ )} + + {/* 分页 */} + {totalPages > 1 && ( +
+ + + {page} / {totalPages} + + +
+ )} + + {/* 删除确认 Dialog */} + !open && setDeleteTarget(null)}> + + + 确认删除文件 + + 确定要删除文件{" "} + 「{deleteTarget?.file_name}」{" "} + 吗?此操作不可撤销。 + + + + 取消 + deleteTarget && deleteMutation.mutate(deleteTarget.id)} + disabled={deleteMutation.isPending} + className="bg-destructive hover:bg-destructive/90 text-destructive-foreground" + > + {deleteMutation.isPending && } + 确认删除 + + + + +
+ ) +} diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index 801ea097..c1f3edfe 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -254,7 +254,7 @@ export function SecurityMain() { setSelectedSource(null) setAuthSourceModalOpen(true) }} - className="bg-indigo-600 hover:bg-indigo-700 text-white shadow-md shadow-indigo-600/10 transition-colors" + variant="secondary" > 新增认证源 diff --git a/frontend/components/layout/sidebar.tsx b/frontend/components/layout/sidebar.tsx index a9a687ec..8543699e 100644 --- a/frontend/components/layout/sidebar.tsx +++ b/frontend/components/layout/sidebar.tsx @@ -49,6 +49,7 @@ import { CreditCard, FileQuestionMark, FileText, + FolderOpen, Home, Layers, LogOut, @@ -64,6 +65,7 @@ import {useUser} from "@/contexts/user-context" const data = { navMain: [ { title: "首页", url: "/home", icon: Home }, + { title: "文件管理", url: "/settings/files", icon: FolderOpen }, ], systemSettings: [ { title: "系统设置", url: "/settings/security", icon: Settings }, diff --git a/frontend/lib/services/index.ts b/frontend/lib/services/index.ts index ef2ed82b..9b03feab 100644 --- a/frontend/lib/services/index.ts +++ b/frontend/lib/services/index.ts @@ -104,6 +104,7 @@ export type { // 用户服务 export { UserService } from './user'; +export type { AccessToken, CreateTokenResponse } from './user'; // 上传服务 export { UploadService } from './upload'; diff --git a/frontend/lib/services/upload/types.ts b/frontend/lib/services/upload/types.ts index f1729251..3adf8975 100644 --- a/frontend/lib/services/upload/types.ts +++ b/frontend/lib/services/upload/types.ts @@ -1,7 +1,62 @@ /** - * 上传图片响应 + * 文件上传元数据 + */ +export interface UploadMetadata { + width?: number + height?: number + duration?: number + original_mime?: string + user_agent?: string + client_ip?: string + bucket?: string + extra?: Record +} + +/** + * 上传记录 + */ +export interface Upload { + id: string + user_id: string + file_name: string + file_path: string + file_size: number + mime_type: string + extension: string + hash: string + storage_driver: string + type: string + status: string + metadata: UploadMetadata + created_at: string + updated_at: string +} + +/** + * 上传接口响应(原 UploadImageResponse 兼容) */ export interface UploadImageResponse { /** 上传记录 ID */ - id: string; + id: string +} + +/** + * 文件列表查询参数 + */ +export interface ListUploadsQuery { + page?: number + page_size?: number + type?: string + extension?: string + keyword?: string +} + +/** + * 文件列表分页响应 + */ +export interface ListUploadsResponse { + total: number + page: number + page_size: number + items: Upload[] } \ No newline at end of file diff --git a/frontend/lib/services/upload/upload.service.ts b/frontend/lib/services/upload/upload.service.ts index 14d64ae8..700945c4 100644 --- a/frontend/lib/services/upload/upload.service.ts +++ b/frontend/lib/services/upload/upload.service.ts @@ -1,6 +1,6 @@ -import { BaseService } from '../core/base.service'; -import type { UploadImageResponse } from './types'; -import type { InternalAxiosRequestConfig } from 'axios'; +import {BaseService} from '../core/base.service' +import type {ListUploadsResponse, Upload, UploadImageResponse} from './types' +import type {InternalAxiosRequestConfig} from 'axios' /** * 根据上传ID构造文件访问URL @@ -8,8 +8,19 @@ import type { InternalAxiosRequestConfig } from 'axios'; * @returns 文件访问URL */ export function getFileUrl(id: string | number | null | undefined): string | null { - if (!id) return null; - return `/f/${id}`; + if (!id) return null + return `/f/${id}` +} + +/** + * 格式化文件大小 + */ +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B' + const k = 1024 + const sizes = ['B', 'KB', 'MB', 'GB'] + const i = Math.floor(Math.log(bytes) / Math.log(k)) + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` } /** @@ -17,72 +28,91 @@ export function getFileUrl(id: string | number | null | undefined): string | nul * 处理文件上传相关的 API 请求 */ export class UploadService extends BaseService { - protected static readonly basePath = '/api/v1/upload'; + protected static readonly basePath = '/api/v1/upload' /** - * 上传红包封面图片 - * @param file - 图片文件 - * @param type - 封面类型 (cover: 背景封面, heterotypic: 异形装饰) - * @returns 上传后的图片URL - * @throws {ValidationError} 当文件格式或大小不符合要求时 - * @throws {UnauthorizedError} 当未登录时 - * - * @example - * ```typescript - * const file = e.target.files[0]; - * const result = await UploadService.uploadRedEnvelopeCover(file, 'cover'); - * console.log('图片URL:', result.url); - * ``` + * 通用文件上传 + * @param file - 文件对象 + * @param type - 业务分类(如 avatar、attachment、generic) + * @param metadata - 可选额外 JSON 元数据 */ - static async uploadRedEnvelopeCover( + static async uploadFile( file: File, - type: 'cover' | 'heterotypic' - ): Promise { - // 验证文件类型 - const allowedTypes = ['image/jpeg', 'image/png', 'image/jpg', 'image/webp']; - if (!allowedTypes.includes(file.type)) { - throw new Error('只支持 JPG、PNG、WEBP 格式的图片'); + type: string = 'generic', + metadata?: Record + ): Promise { + const formData = new FormData() + formData.append('file', file) + formData.append('type', type) + if (metadata) { + formData.append('metadata', JSON.stringify(metadata)) } - // 验证文件大小 (最大 2MB) - const maxSize = 2 * 1024 * 1024; - if (file.size > maxSize) { - throw new Error('图片大小不能超过 2MB'); - } - - // 创建 FormData - const formData = new FormData(); - formData.append('file', file); - formData.append('type', type); - - return this.post('/redenvelope/cover', formData, { - headers: { - 'Content-Type': 'multipart/form-data', - }, - } as InternalAxiosRequestConfig); + return this.post('', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + } as InternalAxiosRequestConfig) } /** - * 将 base64 图片转换为 Blob 并上传 - * @param base64 - base64 编码的图片 - * @param type - 封面类型 - * @param filename - 文件名 - * @returns 上传后的图片URL + * 获取我的文件列表 + * @param page - 页码(1-based) + * @param pageSize - 每页数量 + * @param keyword - 搜索关键词(文件名模糊) + * @param type - 业务分类过滤 + * @param extension - 扩展名过滤 + */ + static async listMyFiles( + page = 1, + pageSize = 20, + keyword?: string, + type?: string, + extension?: string + ): Promise { + const params: Record = { page, page_size: pageSize } + if (keyword) params.keyword = keyword + if (type) params.type = type + if (extension) params.extension = extension + return this.get('/my', { params }) + } + + /** + * 删除文件 + */ + static async deleteFile(id: string): Promise { + return this.delete(`/${id}`) + } + + /** + * 获取单文件下载 URL(触发 attachment 下载) + */ + static getDownloadUrl(id: string): string { + return `/api/v1/upload/download/${id}` + } + + /** + * 批量 ZIP 打包下载 + * @param ids - 文件 ID 数组 + */ + static async batchDownload(ids: string[]): Promise { + const response = await this.post('/download/batch', { ids }, { + responseType: 'blob', + } as InternalAxiosRequestConfig) + return response + } + + /** + * 将 base64 图片转换为 Blob 并上传(兼容旧接口) */ static async uploadBase64Image( base64: string, - type: 'cover' | 'heterotypic', + type: string = 'generic', filename: string = 'image.png' ): Promise { - // 将 base64 转换为 Blob - const response = await fetch(base64); - const blob = await response.blob(); - - // 创建 File 对象,确保正确的 MIME 类型 - const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png'; - const file = new File([blob], filename, { type: mimeType }); - - // 上传文件 - return this.uploadRedEnvelopeCover(file, type); + const response = await fetch(base64) + const blob = await response.blob() + const mimeType = base64.match(/data:([^;]+);/)?.[1] || 'image/png' + const file = new File([blob], filename, { type: mimeType }) + const result = await this.uploadFile(file, type) + return { id: result.id } } -} \ No newline at end of file +} diff --git a/frontend/lib/services/user/index.ts b/frontend/lib/services/user/index.ts index a56f153c..64a825cd 100644 --- a/frontend/lib/services/user/index.ts +++ b/frontend/lib/services/user/index.ts @@ -6,3 +6,4 @@ */ export { UserService } from './user.service'; +export type { AccessToken, CreateTokenResponse } from './user.service'; diff --git a/frontend/lib/services/user/user.service.ts b/frontend/lib/services/user/user.service.ts index 8c4d1985..e9ba36e4 100644 --- a/frontend/lib/services/user/user.service.ts +++ b/frontend/lib/services/user/user.service.ts @@ -1,4 +1,19 @@ -import { BaseService } from '../core/base.service'; +import {BaseService} from '../core/base.service'; + +export interface AccessToken { + id: number; + user_id: number; + name: string; + masked_token: string; + last_used_at?: string; + created_at: string; + updated_at: string; +} + +export interface CreateTokenResponse { + token: string; + record: AccessToken; +} /** * 用户服务 @@ -6,5 +21,35 @@ import { BaseService } from '../core/base.service'; */ export class UserService extends BaseService { protected static readonly basePath = '/api/v1/user'; -} + /** + * 获取当前用户的 AccessToken 列表 + */ + static async getAccessTokens(): Promise { + return this.get('/access-tokens'); + } + + /** + * 创建一个新的 AccessToken + * @param name - 令牌名称 + */ + static async createAccessToken(name: string): Promise { + return this.post('/access-tokens', { name }); + } + + /** + * 删除一个 AccessToken + * @param id - 令牌 ID + */ + static async deleteAccessToken(id: number): Promise { + return this.delete(`/access-tokens/${id}`); + } + + /** + * 轮换一个 AccessToken 密钥 + * @param id - 令牌 ID + */ + static async rotateAccessToken(id: number): Promise { + return this.post(`/access-tokens/${id}/rotate`); + } +} diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go index 34471531..4eb2e997 100644 --- a/internal/apps/oauth/middlewares.go +++ b/internal/apps/oauth/middlewares.go @@ -18,6 +18,7 @@ package oauth import ( "net/http" + "time" "github.com/gin-gonic/gin" "github.com/linux-do/credit/internal/common" @@ -44,19 +45,45 @@ func LoginRequired() gin.HandlerFunc { ctx, span := otel_trace.Start(c.Request.Context(), "LoginRequired") defer span.End() - // load user - userId := GetUserIDFromContext(c) - if userId <= 0 { - c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil}) - return + // 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:] + } } - // load user from db to make sure is active var user model.User - tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user) - if tx.Error != nil { - c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error_msg": tx.Error.Error(), "data": nil}) - return + var authenticated 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 + // update token last used time + now := time.Now() + db.DB(ctx).Model(&tokenRecord).Update("last_used_at", &now) + } + } + } + + 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 + } } // log diff --git a/internal/apps/upload/errs.go b/internal/apps/upload/errs.go index eb18d7b5..5c59a57f 100644 --- a/internal/apps/upload/errs.go +++ b/internal/apps/upload/errs.go @@ -18,7 +18,7 @@ package upload const ( ErrNoFileSelected = "请选择要上传的文件" - ErrInvalidCoverType = "无效的封面类型" + ErrInvalidUploadType = "无效的上传类型" ErrFileTooLarge = "图片大小不能超过 2MB" ErrUnsupportedFormat = "只支持 JPG、PNG、WEBP 格式的图片" ErrInvalidImage = "无效的图片文件" @@ -28,5 +28,5 @@ const ( ErrOpenFileFailed = "打开文件失败" ErrInvalidFilePath = "非法文件路径" ErrSaveUploadRecordFailed = "保存上传记录失败" - ErrQueryHistoryCoverFailed = "查询历史封面失败" + ErrQueryHistoryUploadFailed = "查询历史上传记录失败" ) diff --git a/internal/apps/upload/file_server.go b/internal/apps/upload/file_server.go index 9ef97d3e..afb1ecfa 100644 --- a/internal/apps/upload/file_server.go +++ b/internal/apps/upload/file_server.go @@ -59,6 +59,11 @@ func ServeFileByID(c *gin.Context) { return } + if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) { + c.File(upload.FilePath) + return + } + // Retrieve file from S3 (via CDN if configured) obj, err := storage.GetObjectViaCache(c.Request.Context(), upload.FilePath) if err != nil { diff --git a/internal/apps/upload/routers.go b/internal/apps/upload/routers.go new file mode 100644 index 00000000..a80d3357 --- /dev/null +++ b/internal/apps/upload/routers.go @@ -0,0 +1,534 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package upload + +import ( + "archive/zip" + "bytes" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/common/response" + "github.com/linux-do/credit/internal/config" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/db/idgen" + "github.com/linux-do/credit/internal/logger" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/storage" + "github.com/linux-do/credit/internal/util" + "gorm.io/gorm" +) + +const maxUploadSize = 32 * 1024 * 1024 // 32MB + +type batchDownloadRequest struct { + IDs []string `json:"ids" binding:"required,min=1"` +} + +// UploadFile 通用上传文件接口 +// @Summary 上传文件 +// @Description 支持各种类型的通用文件上传,支持自动文件类型检测、哈希计算与“秒传”去重 +// @Tags upload +// @Accept multipart/form-data +// @Produce json +// @Param file formData file true "要上传的文件" +// @Param type formData string false "业务分类 (例如: avatar, attachment, doc,默认为 generic)" +// @Param metadata formData string false "额外的 JSON 格式元数据" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=model.Upload} "上传成功" +// @Failure 400 {object} util.ResponseAny "请求参数错误或文件受限" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Failure 500 {object} util.ResponseAny "内部错误" +// @Router /api/v1/upload [post] +func UploadFile(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + header, err := c.FormFile("file") + if err != nil { + response.RespondFailure(c, ErrNoFileSelected) + return + } + + file, err := header.Open() + if err != nil { + response.RespondFailure(c, ErrOpenFileFailed) + return + } + defer file.Close() + + // 校验大小 + if header.Size > maxUploadSize { + response.RespondFailure(c, "文件大小不能超过 32MB") + return + } + + // 2. 提取文件基本元数据 + origName := header.Filename + ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(origName), ".")) + if ext == "" { + ext = "bin" + } + + // 3. 校验文件后缀是否在允许的系统配置列表中 + var sc model.SystemConfig + if err := sc.GetByKey(ctx, model.ConfigKeyUploadAllowedExtensions); err == nil && sc.Value != "" { + allowedExts := strings.Split(strings.ToLower(sc.Value), ",") + allowed := false + for _, allowedExt := range allowedExts { + if strings.TrimSpace(allowedExt) == ext { + allowed = true + break + } + } + if !allowed { + response.RespondFailure(c, ErrUnsupportedFormat) + return + } + } + + // 4. 读取文件并计算 Hash + hashWriter := sha256.New() + var buf bytes.Buffer + size, err := io.Copy(&buf, io.TeeReader(file, hashWriter)) + if err != nil { + response.RespondFailure(c, ErrProcessFileFailed) + return + } + + fileHash := hex.EncodeToString(hashWriter.Sum(nil)) + + mimeType := http.DetectContentType(buf.Bytes()[:min(512, int(size))]) + if mimeType == "application/octet-stream" && header.Header.Get("Content-Type") != "" { + mimeType = header.Header.Get("Content-Type") + } + + // 6. 秒传匹配校验:校验数据库中是否存在相同 Hash 且大小一致的可用文件 + var existing model.Upload + err = db.DB(ctx).Where("hash = ? AND file_size = ? AND status IN (?, ?)", fileHash, size, model.UploadStatusPending, model.UploadStatusUsed).First(&existing).Error + if err == nil { + // 命中了相同文件,直接生成新记录指向已有的存储路径(实现秒传) + id := idgen.NextUint64ID() + newUpload := model.Upload{ + ID: id, + UserID: currUser.ID, + FileName: origName, + FilePath: existing.FilePath, + FileSize: size, + MimeType: mimeType, + Extension: ext, + Hash: fileHash, + StorageDriver: existing.StorageDriver, + Type: c.DefaultPostForm("type", "generic"), + Status: model.UploadStatusUsed, + Metadata: existing.Metadata, + } + + if err := db.DB(ctx).Create(&newUpload).Error; err != nil { + response.RespondFailure(c, ErrSaveUploadRecordFailed) + return + } + + logger.InfoF(ctx, "文件触发秒传成功! ID: %d, Path: %s", id, existing.FilePath) + response.RespondSuccess(c, newUpload) + return + } else if !errors.Is(err, gorm.ErrRecordNotFound) { + response.RespondFailure(c, "文件校验失败") + return + } + + // 7. 解析可选元数据字段 + metadataStr := c.DefaultPostForm("metadata", "") + var meta model.UploadMetadata + if metadataStr != "" { + if err := json.Unmarshal([]byte(metadataStr), &meta); err != nil { + response.RespondFailure(c, "元数据 JSON 格式不合法") + return + } + } + + meta.OriginalMime = mimeType + meta.UserAgent = c.Request.UserAgent() + meta.ClientIP = c.ClientIP() + + id := idgen.NextUint64ID() + subPath := fmt.Sprintf("uploads/%s/%d.%s", time.Now().Format("2006/01/02"), id, ext) + + var storageDriver string + + // 8. 写入底层存储驱动 (优先 S3 驱动,无配置或未开启则 fallback 至本地文件) + if storage.IsEnabled() { + storageDriver = "s3" + meta.Bucket = config.Config.S3.Bucket + fullKey := storage.BuildKey(subPath) + + err = storage.PutObject(ctx, fullKey, bytes.NewReader(buf.Bytes()), size, mimeType) + if err != nil { + logger.ErrorF(ctx, "S3 存储上传失败: %v", err) + response.RespondFailure(c, ErrSaveFileFailed) + return + } + } else { + storageDriver = "local" + localDir := filepath.Join("uploads", time.Now().Format("2006/01/02")) + if err := os.MkdirAll(localDir, 0755); err != nil { + logger.ErrorF(ctx, "创建本地上传目录失败: %v", err) + response.RespondFailure(c, ErrSaveFileFailed) + return + } + + localPath := filepath.Join(localDir, fmt.Sprintf("%d.%s", id, ext)) + if err := os.WriteFile(localPath, buf.Bytes(), 0644); err != nil { + logger.ErrorF(ctx, "本地磁盘写入文件失败: %v", err) + response.RespondFailure(c, ErrSaveFileFailed) + return + } + // 统一使用相对路径,方便将来环境移植或备份 + subPath = localPath + } + + // 9. 保存文件记录至数据库 + newUpload := model.Upload{ + ID: id, + UserID: currUser.ID, + FileName: origName, + FilePath: subPath, + FileSize: size, + MimeType: mimeType, + Extension: ext, + Hash: fileHash, + StorageDriver: storageDriver, + Type: c.DefaultPostForm("type", "generic"), + Status: model.UploadStatusUsed, + Metadata: meta, + } + + if err := db.DB(ctx).Create(&newUpload).Error; err != nil { + // 失败时若为本地存储,可以尝试清理已保存的垃圾文件 + if storageDriver == "local" { + _ = os.Remove(subPath) + } + response.RespondFailure(c, ErrSaveUploadRecordFailed) + return + } + + response.RespondSuccess(c, newUpload) +} + +// DownloadFile 通用单文件下载接口 +// @Summary 下载单文件 +// @Description 根据文件 ID 获取文件,以附件形式 (Attachment) 强制开启客户端浏览器下载 +// @Tags upload +// @Produce octet-stream +// @Param id path string true "文件 ID" +// @Security SessionCookie +// @Success 200 {file} file "成功下载文件" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Failure 404 {object} util.ResponseAny "文件不存在" +// @Failure 500 {object} util.ResponseAny "服务内部错误" +// @Router /api/v1/upload/download/{id} [get] +func DownloadFile(c *gin.Context) { + ctx := c.Request.Context() + idStr := c.Param("id") + uploadID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.RespondFailure(c, "无效的文件 ID") + return + } + + var upload model.Upload + if err := db.DB(ctx).Where("id = ? AND status IN (?, ?)", uploadID, model.UploadStatusPending, model.UploadStatusUsed).First(&upload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatus(http.StatusNotFound) + return + } + response.RespondFailure(c, "查询文件记录失败") + return + } + + // 设置下载 Attachment 响应头 (支持 UTF-8 中文文件名转义) + c.Header("Content-Disposition", fmt.Sprintf("attachment; filename*=UTF-8''%s", url.PathEscape(upload.FileName))) + c.Header("Content-Type", upload.MimeType) + c.Header("Content-Length", strconv.FormatInt(upload.FileSize, 10)) + + // 根据存储驱动类型提供流式文件服务 + if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) { + c.File(upload.FilePath) + return + } + + // 从 S3/CDN 加载并返回 + obj, err := storage.GetObjectViaCache(ctx, upload.FilePath) + if err != nil { + c.AbortWithStatus(http.StatusNotFound) + return + } + + if obj.CachePath != "" { + c.File(obj.CachePath) + return + } + + defer obj.Body.Close() + _, _ = io.Copy(c.Writer, obj.Body) +} + +// BatchDownloadFiles 批量打包 ZIP 下载接口 +// @Summary 批量打包下载 +// @Description 传入多个文件 ID,后台实时将其打包压缩为 ZIP 流并输出,自动处理文件名重复冲突 +// @Tags upload +// @Accept json +// @Produce octet-stream +// @Param request body upload.batchDownloadRequest true "包含文件 ID 数组的请求体" +// @Security SessionCookie +// @Success 200 {file} file "成功下载打包后的 ZIP" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Failure 500 {object} util.ResponseAny "打包失败" +// @Router /api/v1/upload/download/batch [post] +func BatchDownloadFiles(c *gin.Context) { + ctx := c.Request.Context() + + var req batchDownloadRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondFailure(c, "参数绑定失败,请传入有效的文件 ID 数组") + return + } + + // 转换 ID 列表 + var ids []uint64 + for _, idStr := range req.IDs { + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.RespondFailure(c, fmt.Sprintf("无效的 ID 值: %s", idStr)) + return + } + ids = append(ids, id) + } + + // 查库获取所有匹配且正常的文件记录 + var uploads []model.Upload + if err := db.DB(ctx).Where("id IN ? AND status IN (?, ?)", ids, model.UploadStatusPending, model.UploadStatusUsed).Find(&uploads).Error; err != nil { + response.RespondFailure(c, "检索文件记录失败") + return + } + + if len(uploads) == 0 { + response.RespondFailure(c, "没有找到任何有效的文件记录进行打包") + return + } + + // 设置 ZIP 格式流的响应头 + c.Header("Content-Type", "application/zip") + c.Header("Content-Disposition", "attachment; filename=\"batch_download.zip\"") + + // 开启实时 ZIP 压缩器并直接输出给 Response Writer + zipWriter := zip.NewWriter(c.Writer) + defer zipWriter.Close() + + // 用于解决 ZIP 内部文件名称发生碰撞冲突的问题 + usedNames := make(map[string]int) + + for _, upload := range uploads { + // 校验防冲突重命名逻辑 + fileName := upload.FileName + if count, exists := usedNames[fileName]; exists { + usedNames[fileName] = count + 1 + ext := filepath.Ext(fileName) + base := strings.TrimSuffix(fileName, ext) + fileName = fmt.Sprintf("%s_%d%s", base, count, ext) + } else { + usedNames[fileName] = 1 + } + + // 在 ZIP 包内建新条目 + zipFileEntry, err := zipWriter.Create(fileName) + if err != nil { + logger.ErrorF(ctx, "ZIP 添加条目失败 [%s]: %v", fileName, err) + continue + } + + // 打开底层文件数据源 + var rc io.ReadCloser + if upload.StorageDriver == "local" || (upload.StorageDriver == "" && !storage.IsEnabled()) { + fileSrc, err := os.Open(upload.FilePath) + if err != nil { + logger.ErrorF(ctx, "打包时读取本地文件失败: %v", err) + continue + } + rc = fileSrc + } else { + obj, err := storage.GetObject(ctx, upload.FilePath) + if err != nil { + logger.ErrorF(ctx, "打包时拉取 S3 文件失败: %v", err) + continue + } + rc = obj.Body + } + + // 流式拷贝到 ZIP entry + _, err = io.Copy(zipFileEntry, rc) + _ = rc.Close() + if err != nil { + logger.ErrorF(ctx, "写入 ZIP 流失败: %v", err) + } + } +} + +type listMyFilesRequest struct { + Page int `form:"page"` + PageSize int `form:"page_size"` + Keyword string `form:"keyword"` + Type string `form:"type"` + Extension string `form:"extension"` +} + +type listMyFilesResponse struct { + Total int64 `json:"total"` + Page int `json:"page"` + PageSize int `json:"page_size"` + Items []model.Upload `json:"items"` +} + +// ListMyFiles 获取当前用户上传的文件列表 +// @Summary 获取我的文件列表 +// @Description 分页获取当前登录用户上传的文件,支持文件名关键词、业务类型、扩展名过滤 +// @Tags upload +// @Produce json +// @Param page query int false "页码(默认 1)" +// @Param page_size query int false "每页数量(默认 20,最大 100)" +// @Param keyword query string false "文件名关键词(模糊匹配)" +// @Param type query string false "业务分类过滤" +// @Param extension query string false "扩展名过滤" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=listMyFilesResponse} "查询成功" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Router /api/v1/upload/my [get] +func ListMyFiles(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + var req listMyFilesRequest + if err := c.ShouldBindQuery(&req); err != nil { + response.RespondFailure(c, "参数错误") + return + } + if req.Page <= 0 { + req.Page = 1 + } + if req.PageSize <= 0 || req.PageSize > 100 { + req.PageSize = 20 + } + + query := db.DB(ctx).Model(&model.Upload{}). + Where("user_id = ? AND status != ?", currUser.ID, model.UploadStatusDeleted) + + if req.Keyword != "" { + query = query.Where("file_name ILIKE ?", "%"+req.Keyword+"%") + } + if req.Type != "" { + query = query.Where("type = ?", req.Type) + } + if req.Extension != "" { + query = query.Where("extension = ?", strings.ToLower(req.Extension)) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + response.RespondFailure(c, "查询文件数量失败") + return + } + + var items []model.Upload + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&items).Error; err != nil { + response.RespondFailure(c, "查询文件列表失败") + return + } + + response.RespondSuccess(c, listMyFilesResponse{ + Total: total, + Page: req.Page, + PageSize: req.PageSize, + Items: items, + }) +} + +// DeleteFile 软删除文件记录 +// @Summary 删除文件 +// @Description 将文件状态置为 deleted(软删除),不会立即清理底层存储对象 +// @Tags upload +// @Produce json +// @Param id path string true "文件 ID" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny "删除成功" +// @Failure 403 {object} util.ResponseAny "无权操作" +// @Failure 404 {object} util.ResponseAny "文件不存在" +// @Router /api/v1/upload/{id} [delete] +func DeleteFile(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + idStr := c.Param("id") + uploadID, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.RespondFailure(c, "无效的文件 ID") + return + } + + var upload model.Upload + if err := db.DB(ctx).Where("id = ? AND status != ?", uploadID, model.UploadStatusDeleted).First(&upload).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + c.AbortWithStatus(http.StatusNotFound) + return + } + response.RespondFailure(c, "查询文件记录失败") + return + } + + // 仅允许文件所有者或管理员删除 + if upload.UserID != currUser.ID && !currUser.IsAdmin { + c.AbortWithStatus(http.StatusForbidden) + return + } + + if err := db.DB(ctx).Model(&upload).Update("status", model.UploadStatusDeleted).Error; err != nil { + response.RespondFailure(c, "删除文件失败") + return + } + + response.RespondSuccess(c, nil) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/internal/apps/upload/routers_test.go b/internal/apps/upload/routers_test.go new file mode 100644 index 00000000..0440bd9c --- /dev/null +++ b/internal/apps/upload/routers_test.go @@ -0,0 +1,516 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package upload + +import ( + "archive/zip" + "bytes" + "context" + "encoding/json" + "io" + "mime/multipart" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/storage" + "github.com/linux-do/credit/internal/testhelper" + "github.com/linux-do/credit/internal/util" +) + +type testResponse struct { + Success bool `json:"success"` + Message string `json:"message"` + Data json.RawMessage `json:"data"` +} + +func setupTestRouter(authUser *model.User) *gin.Engine { + gin.SetMode(gin.TestMode) + r := gin.New() + uploadGroup := r.Group("/api/v1/upload") + + // Mock authentication middleware + uploadGroup.Use(func(c *gin.Context) { + if authUser != nil { + util.SetToContext(c, oauth.UserObjKey, authUser) + } + c.Next() + }) + + uploadGroup.POST("", UploadFile) + uploadGroup.GET("/download/:id", DownloadFile) + uploadGroup.POST("/download/batch", BatchDownloadFiles) + return r +} + +func createMultipartRequest(t *testing.T, fieldName, fileName string, fileContent []byte, extraFields map[string]string) (string, *bytes.Buffer) { + body := &bytes.Buffer{} + writer := multipart.NewWriter(body) + + part, err := writer.CreateFormFile(fieldName, fileName) + if err != nil { + t.Fatalf("failed to create form file: %v", err) + } + + _, err = part.Write(fileContent) + if err != nil { + t.Fatalf("failed to write file content: %v", err) + } + + for k, v := range extraFields { + err = writer.WriteField(k, v) + if err != nil { + t.Fatalf("failed to write form field: %v", err) + } + } + + err = writer.Close() + if err != nil { + t.Fatalf("failed to close multipart writer: %v", err) + } + + return writer.FormDataContentType(), body +} + +func TestUploadFile(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + defer os.RemoveAll("uploads") // Clean up local files created during tests + + authUser := &model.User{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Mock Storage Client + mockFiles := make(map[string][]byte) + var putCount int + + restoreStorage := storage.MockStorage( + func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { + data, err := io.ReadAll(body) + if err != nil { + return err + } + mockFiles[key] = data + putCount++ + return nil + }, + func(ctx context.Context, key string) (*storage.ObjectInfo, error) { + data, ok := mockFiles[key] + if !ok { + return nil, os.ErrNotExist + } + return &storage.ObjectInfo{ + Body: io.NopCloser(bytes.NewReader(data)), + ContentLength: int64(len(data)), + ContentType: "application/octet-stream", + }, nil + }, + func(ctx context.Context, key string) error { + delete(mockFiles, key) + return nil + }, + ) + defer restoreStorage() + + // 开启 S3 Storage + storage.IsEnabledFunc = func() bool { return true } + defer func() { + storage.IsEnabledFunc = func() bool { return false } + }() + + t.Run("upload allowed image file successfully", func(t *testing.T) { + putCount = 0 + imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00\x1f\x15\xc4\x89") // Valid PNG header + contentType, body := createMultipartRequest(t, "file", "test.png", imgContent, map[string]string{ + "type": "avatar", + "metadata": `{"extra":{"source":"test_runner"}}`, + }) + + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("failed to unmarshal response: %v", err) + } + + if !resp.Success { + t.Fatalf("expected success response, got failure: %s", resp.Message) + } + + // Verify database record + var uploadRecord model.Upload + if err := json.Unmarshal(resp.Data, &uploadRecord); err != nil { + t.Fatalf("failed to unmarshal upload record: %v", err) + } + + var dbRecord model.Upload + if err := dbConn.First(&dbRecord, uploadRecord.ID).Error; err != nil { + t.Fatalf("failed to retrieve database record: %v", err) + } + + if dbRecord.FileName != "test.png" || dbRecord.Extension != "png" { + t.Errorf("incorrect filename or extension: %s, %s", dbRecord.FileName, dbRecord.Extension) + } + + if dbRecord.MimeType != "image/png" { + t.Errorf("incorrect mime type detected: %s", dbRecord.MimeType) + } + + if dbRecord.StorageDriver != "s3" { + t.Errorf("expected storage driver s3, got %s", dbRecord.StorageDriver) + } + + if dbRecord.Metadata.Extra["source"] != "test_runner" { + t.Errorf("expected extra meta 'source' to be 'test_runner', got %v", dbRecord.Metadata.Extra) + } + + if putCount != 1 { + t.Errorf("expected 1 storage Put operation, got %d", putCount) + } + }) + + t.Run("upload blocked extension file", func(t *testing.T) { + // System config allowed: jpg,png,webp. Uploading docx should be blocked. + contentType, body := createMultipartRequest(t, "file", "contract.docx", []byte("fake docx content"), nil) + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + json.Unmarshal(w.Body.Bytes(), &resp) + if resp.Success || !strings.Contains(resp.Message, ErrUnsupportedFormat) { + t.Errorf("expected unsupported format error, got: %v", resp) + } + }) + + t.Run("instant upload deduplication (秒传)", func(t *testing.T) { + putCount = 0 + imgContent := []byte("\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR\x00\x00\x00\x01\x00\x00\x00\x01") + + // Upload first time + contentType1, body1 := createMultipartRequest(t, "file", "avatar1.png", imgContent, map[string]string{"type": "avatar"}) + req1, _ := http.NewRequest("POST", "/api/v1/upload", body1) + req1.Header.Set("Content-Type", contentType1) + w1 := httptest.NewRecorder() + router.ServeHTTP(w1, req1) + + if w1.Code != http.StatusOK { + t.Fatalf("first upload failed: %s", w1.Body.String()) + } + if putCount != 1 { + t.Errorf("expected 1 put count on first upload, got %d", putCount) + } + + // Upload same file second time (different filename, same content) + contentType2, body2 := createMultipartRequest(t, "file", "avatar2.png", imgContent, map[string]string{"type": "avatar"}) + req2, _ := http.NewRequest("POST", "/api/v1/upload", body2) + req2.Header.Set("Content-Type", contentType2) + w2 := httptest.NewRecorder() + router.ServeHTTP(w2, req2) + + if w2.Code != http.StatusOK { + t.Fatalf("second upload failed: %s", w2.Body.String()) + } + + var resp2 testResponse + json.Unmarshal(w2.Body.Bytes(), &resp2) + + if !resp2.Success { + t.Fatalf("second upload was unsuccessful: %s", resp2.Message) + } + + var uploadRecord2 model.Upload + if err := json.Unmarshal(resp2.Data, &uploadRecord2); err != nil { + t.Fatalf("failed to unmarshal second upload record: %v", err) + } + + // Check if it triggered another storage put + if putCount != 1 { + t.Errorf("PutObject was triggered again! Expected deduplication (putCount=1), got putCount=%d", putCount) + } + + // Check if database contains both records sharing the same FilePath + var records []model.Upload + dbConn.Where("hash = ?", uploadRecord2.Hash).Find(&records) + if len(records) != 2 { + t.Errorf("expected 2 database records sharing the same hash, got %d", len(records)) + } + if records[0].FilePath != records[1].FilePath { + t.Errorf("file paths are different: %s vs %s", records[0].FilePath, records[1].FilePath) + } + if records[0].ID == records[1].ID { + t.Error("database record IDs should be unique") + } + + t.Logf("Instant upload success. Record 1: %d, Record 2: %d", records[0].ID, records[1].ID) + }) + + t.Run("upload in local storage fallback mode", func(t *testing.T) { + // Turn off S3 + storage.IsEnabledFunc = func() bool { return false } + + // Seed allowed extensions configuration to allow txt files + var sc model.SystemConfig + dbConn.Where("key = ?", model.ConfigKeyUploadAllowedExtensions).First(&sc) + sc.Value = "jpg,png,webp,txt" + dbConn.Save(&sc) + _ = db.HSetJSON(context.Background(), model.SystemConfigRedisHashKey, sc.Key, &sc) + + contentType, body := createMultipartRequest(t, "file", "doc.txt", []byte("hello world generic document file"), map[string]string{ + "type": "document", + }) + req, _ := http.NewRequest("POST", "/api/v1/upload", body) + req.Header.Set("Content-Type", contentType) + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + var resp testResponse + json.Unmarshal(w.Body.Bytes(), &resp) + + if !resp.Success { + t.Fatalf("local upload failed: %s", resp.Message) + } + + var localRecord model.Upload + if err := json.Unmarshal(resp.Data, &localRecord); err != nil { + t.Fatalf("failed to unmarshal local upload record: %v", err) + } + + if localRecord.StorageDriver != "local" { + t.Errorf("expected storage driver local, got %s", localRecord.StorageDriver) + } + + // Confirm file was actually written to local disk + fileContent, err := os.ReadFile(localRecord.FilePath) + if err != nil { + t.Fatalf("failed to read local file: %v", err) + } + + if string(fileContent) != "hello world generic document file" { + t.Errorf("unexpected local file contents: %s", string(fileContent)) + } + }) +} + +func TestDownloadFile(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + defer os.RemoveAll("uploads") + + authUser := &model.User{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Seed upload records in DB + localUpload := model.Upload{ + ID: 2001, + UserID: 1001, + FileName: "中文文件名.txt", + FilePath: "uploads/test_download.txt", + FileSize: 12, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Status: model.UploadStatusUsed, + } + + // Create local file + err := os.MkdirAll("uploads", 0755) + if err != nil { + t.Fatalf("failed to create directory: %v", err) + } + err = os.WriteFile(localUpload.FilePath, []byte("hello download"), 0644) + if err != nil { + t.Fatalf("failed to write file: %v", err) + } + + dbConn.Create(&localUpload) + + t.Run("download file successfully", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/upload/download/2001", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + if w.Body.String() != "hello download" { + t.Errorf("expected body 'hello download', got '%s'", w.Body.String()) + } + + // Verify Content-Disposition header (supports UTF-8 escaping) + contentDisp := w.Header().Get("Content-Disposition") + expectedDisp := "attachment; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%96%87%E4%BB%B6%E5%90%8D.txt" + if contentDisp != expectedDisp { + t.Errorf("expected Content-Disposition header %q, got %q", expectedDisp, contentDisp) + } + + if w.Header().Get("Content-Type") != "text/plain" { + t.Errorf("expected Content-Type text/plain, got %s", w.Header().Get("Content-Type")) + } + }) + + t.Run("download non-existent file", func(t *testing.T) { + req, _ := http.NewRequest("GET", "/api/v1/upload/download/9999", nil) + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusNotFound { + t.Errorf("expected status 404, got %d", w.Code) + } + }) +} + +func TestBatchDownloadFiles(t *testing.T) { + dbConn, _, cleanup := testhelper.SetupTestEnvironment(t) + defer cleanup() + defer os.RemoveAll("uploads") + + authUser := &model.User{ID: 1001, Username: "test_user"} + router := setupTestRouter(authUser) + + // Create and write files locally + err := os.MkdirAll("uploads", 0755) + if err != nil { + t.Fatalf("failed to create local dir: %v", err) + } + + _ = os.WriteFile("uploads/f1.txt", []byte("file1 content"), 0644) + _ = os.WriteFile("uploads/f2.txt", []byte("file2 content"), 0644) + _ = os.WriteFile("uploads/f3.txt", []byte("duplicate name file content"), 0644) + + // Seed upload records. Note f2 and f3 have the same FileName "file_a.txt" to trigger name collision resolution. + uploads := []model.Upload{ + { + ID: 3001, + UserID: 1001, + FileName: "file_a.txt", + FilePath: "uploads/f1.txt", + FileSize: 13, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Status: model.UploadStatusUsed, + }, + { + ID: 3002, + UserID: 1001, + FileName: "file_b.txt", + FilePath: "uploads/f2.txt", + FileSize: 13, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Status: model.UploadStatusUsed, + }, + { + ID: 3003, + UserID: 1001, + FileName: "file_a.txt", // COLLISION with 3001! + FilePath: "uploads/f3.txt", + FileSize: 28, + MimeType: "text/plain", + Extension: "txt", + StorageDriver: "local", + Status: model.UploadStatusUsed, + }, + } + + for _, up := range uploads { + dbConn.Create(&up) + } + + t.Run("batch download zip successfully and check duplicate renaming", func(t *testing.T) { + reqBody, _ := json.Marshal(batchDownloadRequest{ + IDs: []string{"3001", "3002", "3003"}, + }) + req, _ := http.NewRequest("POST", "/api/v1/upload/download/batch", bytes.NewReader(reqBody)) + req.Header.Set("Content-Type", "application/json") + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d. Body: %s", w.Code, w.Body.String()) + } + + if w.Header().Get("Content-Type") != "application/zip" { + t.Errorf("expected Content-Type application/zip, got %s", w.Header().Get("Content-Type")) + } + + // Unzip in-memory + zipReader, err := zip.NewReader(bytes.NewReader(w.Body.Bytes()), int64(w.Body.Len())) + if err != nil { + t.Fatalf("failed to read zip buffer: %v", err) + } + + if len(zipReader.File) != 3 { + t.Errorf("expected 3 files inside the ZIP, got %d", len(zipReader.File)) + } + + // Extract files to check their contents and name collision resolutions + extracted := make(map[string]string) + for _, f := range zipReader.File { + rc, err := f.Open() + if err != nil { + t.Fatalf("failed to open zip file entry %s: %v", f.Name, err) + } + content, _ := io.ReadAll(rc) + rc.Close() + extracted[f.Name] = string(content) + } + + // Checks + if extracted["file_a.txt"] != "file1 content" { + t.Errorf("file_a.txt content incorrect: %q", extracted["file_a.txt"]) + } + if extracted["file_b.txt"] != "file2 content" { + t.Errorf("file_b.txt content incorrect: %q", extracted["file_b.txt"]) + } + // The second file_a.txt should be renamed to file_a_1.txt + if extracted["file_a_1.txt"] != "duplicate name file content" { + t.Errorf("file_a_1.txt content incorrect: %q. Extracted files: %v", extracted["file_a_1.txt"], extracted) + } + + t.Logf("Successfully unzipped batch. Extracted files: %+v", extracted) + }) +} diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go new file mode 100644 index 00000000..ff7d95e4 --- /dev/null +++ b/internal/apps/user/access_tokens.go @@ -0,0 +1,219 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +import ( + "strconv" + "strings" + + "github.com/gin-gonic/gin" + "github.com/linux-do/credit/internal/apps/oauth" + "github.com/linux-do/credit/internal/common/response" + "github.com/linux-do/credit/internal/db" + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/util" +) + +type createTokenRequest struct { + Name string `json:"name"` +} + +type tokenResponse struct { + Token string `json:"token"` + Record model.AccessToken `json:"record"` +} + +// ListAccessTokens 获取当前用户的 AccessToken 列表 +// @Summary 获取当前用户的 AccessToken 列表 +// @Description 返回当前登录用户的所有 active access tokens(脱敏后) +// @Tags user +// @Produce json +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=[]model.AccessToken} "令牌列表" +// @Failure 401 {object} util.ResponseAny "未登录" +// @Router /api/v1/user/access-tokens [get] +func ListAccessTokens(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + var tokens []model.AccessToken + if err := db.DB(ctx).Where("user_id = ?", currUser.ID).Order("created_at desc").Find(&tokens).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + + response.RespondSuccess(c, tokens) +} + +// CreateAccessToken 创建一个新的 AccessToken +// @Summary 创建一个新的 AccessToken +// @Description 为当前用户新建一个 API 访问令牌,仅在此接口返回一次明文令牌值,请妥善保存。 +// @Tags user +// @Accept json +// @Produce json +// @Param request body user.createTokenRequest true "令牌名称" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=user.tokenResponse} "新建令牌成功" +// @Failure 400 {object} util.ResponseAny "参数错误或超限" +// @Router /api/v1/user/access-tokens [post] +func CreateAccessToken(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + var req createTokenRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.RespondFailure(c, "参数绑定失败") + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + response.RespondFailure(c, "令牌名称不能为空") + return + } + + // 检查最大限制(基于 ConfigKeyMaxAPIKeysPerUser 配置,默认值为 5) + maxLimit := 5 + if val, err := model.GetIntByKey(ctx, model.ConfigKeyMaxAPIKeysPerUser); err == nil { + maxLimit = val + } + + var count int64 + if err := db.DB(ctx).Model(&model.AccessToken{}).Where("user_id = ?", currUser.ID).Count(&count).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + + if int(count) >= maxLimit { + response.RespondFailure(c, "已达到访问令牌最大创建数量限制") + return + } + + // 生成 Token + tokenStr, err := model.GenerateTokenString() + if err != nil { + response.RespondFailure(c, "生成令牌失败") + return + } + + tokenHash := model.HashToken(tokenStr) + maskedToken := model.MaskTokenString(tokenStr) + + tokenRecord := model.AccessToken{ + UserID: currUser.ID, + Name: req.Name, + TokenHash: tokenHash, + MaskedToken: maskedToken, + } + + if err := db.DB(ctx).Create(&tokenRecord).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + + response.RespondSuccess(c, tokenResponse{ + Token: tokenStr, + Record: tokenRecord, + }) +} + +// DeleteAccessToken 删除一个 AccessToken +// @Summary 删除一个 AccessToken +// @Description 撤销并删除一个属于当前用户的 API 访问令牌 +// @Tags user +// @Produce json +// @Param id path string true "令牌ID" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=string} "删除成功" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Router /api/v1/user/access-tokens/{id} [delete] +func DeleteAccessToken(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.RespondFailure(c, "无效的令牌ID") + return + } + + tx := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).Delete(&model.AccessToken{}) + if tx.Error != nil { + response.RespondFailure(c, tx.Error.Error()) + return + } + + if tx.RowsAffected == 0 { + response.RespondFailure(c, "令牌不存在或无权操作") + return + } + + response.RespondSuccess(c, "删除成功") +} + +// RotateAccessToken 轮换一个 AccessToken +// @Summary 轮换一个 AccessToken +// @Description 轮换(重新生成)一个属于当前用户的 API 访问令牌的密钥,旧令牌将立即失效 +// @Tags user +// @Produce json +// @Param id path string true "令牌ID" +// @Security SessionCookie +// @Success 200 {object} util.ResponseAny{data=user.tokenResponse} "令牌轮换成功" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Router /api/v1/user/access-tokens/{id}/rotate [post] +func RotateAccessToken(c *gin.Context) { + currUser, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey) + ctx := c.Request.Context() + + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + response.RespondFailure(c, "无效的令牌ID") + return + } + + var tokenRecord model.AccessToken + if err := db.DB(ctx).Where("id = ? AND user_id = ?", id, currUser.ID).First(&tokenRecord).Error; err != nil { + response.RespondFailure(c, "令牌不存在或无权操作") + return + } + + // 生成新的 Token + newTokenStr, err := model.GenerateTokenString() + if err != nil { + response.RespondFailure(c, "生成令牌失败") + return + } + + newTokenHash := model.HashToken(newTokenStr) + newMaskedToken := model.MaskTokenString(newTokenStr) + + tokenRecord.TokenHash = newTokenHash + tokenRecord.MaskedToken = newMaskedToken + tokenRecord.LastUsedAt = nil // 轮换后重置使用时间 + + if err := db.DB(ctx).Save(&tokenRecord).Error; err != nil { + response.RespondFailure(c, err.Error()) + return + } + + response.RespondSuccess(c, tokenResponse{ + Token: newTokenStr, + Record: tokenRecord, + }) +} diff --git a/internal/config/model.go b/internal/config/model.go index 1923fb0f..86d7a856 100644 --- a/internal/config/model.go +++ b/internal/config/model.go @@ -27,7 +27,6 @@ type configModel struct { Scheduler schedulerConfig `mapstructure:"scheduler"` Worker workerConfig `mapstructure:"worker"` ClickHouse clickHouseConfig `mapstructure:"clickhouse"` - LinuxDo linuxDoConfig `mapstructure:"linuxdo"` OpenAPIRisk openAPIRiskConfig `mapstructure:"openapi_risk"` Otel otelConfig `mapstructure:"otel"` S3 s3Config `mapstructure:"s3"` @@ -42,7 +41,6 @@ type appConfig struct { APIPrefix string `mapstructure:"api_prefix"` GracefulShutdownTimeout int `mapstructure:"graceful_shutdown_timeout"` FrontendURL string `mapstructure:"frontend_url"` - FrontendPayURL string `mapstructure:"frontend_pay_url"` SessionCookieName string `mapstructure:"session_cookie_name"` SessionSecret string `mapstructure:"session_secret"` SessionDomain string `mapstructure:"session_domain"` @@ -163,11 +161,6 @@ type QueueConfig struct { Priority int `mapstructure:"priority"` } -// linuxDoConfig -type linuxDoConfig struct { - ApiKey string `mapstructure:"api_key"` -} - // openAPIRiskConfig OpenAPI 用户风险配置 type openAPIRiskConfig struct { Enabled bool `mapstructure:"enabled"` diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index 412099f2..3144abfb 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -37,6 +37,7 @@ func Migrate() { &model.ExternalAccount{}, &model.SystemConfig{}, &model.Upload{}, + &model.AccessToken{}, ); err != nil { log.Fatalf("[PostgreSQL] auto migrate failed: %v\n", err) } diff --git a/internal/model/access_token.go b/internal/model/access_token.go new file mode 100644 index 00000000..c73e5148 --- /dev/null +++ b/internal/model/access_token.go @@ -0,0 +1,60 @@ +/* +Copyright 2025 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package model + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "fmt" + "time" +) + +type AccessToken struct { + ID uint64 `json:"id" gorm:"primaryKey;autoIncrement"` + UserID uint64 `json:"user_id" gorm:"index;not null"` + Name string `json:"name" gorm:"size:128;not null"` + TokenHash string `json:"-" gorm:"size:64;uniqueIndex;not null"` + MaskedToken string `json:"masked_token" gorm:"size:64;not null"` + LastUsedAt *time.Time `json:"last_used_at"` + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` +} + +// GenerateTokenString 生成加密安全的随机 Token 值 +func GenerateTokenString() (string, error) { + bytes := make([]byte, 24) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return fmt.Sprintf("at_%s", hex.EncodeToString(bytes)), nil +} + +// HashToken 计算 Token 的 SHA-256 哈希值用于数据库存储与查询 +func HashToken(token string) string { + h := sha256.New() + h.Write([]byte(token)) + return hex.EncodeToString(h.Sum(nil)) +} + +// MaskTokenString 生成脱敏显示的 Token,仅保留前缀和最后四位 +func MaskTokenString(token string) string { + if len(token) <= 8 { + return "at_****" + } + return fmt.Sprintf("%s...%s", token[:7], token[len(token)-4:]) +} diff --git a/internal/model/uploads.go b/internal/model/uploads.go index 206a310d..f26d65ab 100644 --- a/internal/model/uploads.go +++ b/internal/model/uploads.go @@ -29,20 +29,32 @@ const ( UploadStatusDeleted UploadStatus = "deleted" // 已删除 ) -// UploadType 上传类型常量 -const ( - UploadTypeCover = "cover" // 红包背景封面 - UploadTypeHeterotypic = "heterotypic" // 红包异形装饰 -) +// UploadMetadata 自定义可扩展的 JSON 字段存储非核心或可选的文件元数据 +type UploadMetadata struct { + Width int `json:"width,omitempty"` // 图像/视频宽度 (px) + Height int `json:"height,omitempty"` // 图像/视频高度 (px) + Duration float64 `json:"duration,omitempty"` // 音视频时长 (s) + OriginalMime string `json:"original_mime,omitempty"` // 原始 MIME 类型 + UserAgent string `json:"user_agent,omitempty"` // 上传者的 UA + ClientIP string `json:"client_ip,omitempty"` // 上传者 IP + Bucket string `json:"bucket,omitempty"` // 存储桶名称 (适用于 S3 等) + Extra map[string]any `json:"extra,omitempty"` // 其它任意业务自定义元数据 +} // Upload 上传文件记录 type Upload struct { - ID uint64 `json:"id,string" gorm:"primaryKey"` - UserID uint64 `json:"user_id,string" gorm:"index;not null"` - FilePath string `json:"file_path" gorm:"size:500;not null;uniqueIndex"` // 文件路径 - FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节) - Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 类型 (cover, heterotypic) - Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态 - CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` - UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` + ID uint64 `json:"id,string" gorm:"primaryKey"` + UserID uint64 `json:"user_id,string" gorm:"index;not null"` + FileName string `json:"file_name" gorm:"size:255;not null"` // 原始文件名 (例如: image.png) + FilePath string `json:"file_path" gorm:"size:500;not null;index"` // 文件相对路径 / S3 Key + FileSize int64 `json:"file_size" gorm:"not null"` // 文件大小(字节) + MimeType string `json:"mime_type" gorm:"size:100;not null"` // 媒体类型 (MIME, 如 image/png) + Extension string `json:"extension" gorm:"size:50;not null"` // 文件后缀名 (不含点,如 png, pdf) + Hash string `json:"hash" gorm:"size:64;index"` // 文件哈希 (SHA-256/MD5,可用于排重) + StorageDriver string `json:"storage_driver" gorm:"size:50;not null"` // 存储引擎驱动 (如 local, s3, oss) + Type string `json:"type" gorm:"column:type;size:50;not null;index"` // 业务标识类型 (如 avatar, doc, attachment) + Status UploadStatus `json:"status" gorm:"type:varchar(20);not null"` // 状态 + Metadata UploadMetadata `json:"metadata" gorm:"serializer:json;type:jsonb"` // 业务扩展元数据 + CreatedAt time.Time `json:"created_at" gorm:"autoCreateTime"` + UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime"` } diff --git a/internal/router/router.go b/internal/router/router.go index a2fc814a..14d86d5c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -127,13 +127,27 @@ func Serve() { userRouter.POST("/register", user.Register) userRouter.GET("/logout", user.Logout) userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) + + // Access Token + tokenRouter := userRouter.Group("/access-tokens") + tokenRouter.Use(oauth.LoginRequired()) + { + tokenRouter.GET("", user.ListAccessTokens) + tokenRouter.POST("", user.CreateAccessToken) + tokenRouter.DELETE("/:id", user.DeleteAccessToken) + tokenRouter.POST("/:id/rotate", user.RotateAccessToken) + } } // Upload uploadRouter := apiV1Router.Group("/upload") uploadRouter.Use(oauth.LoginRequired()) { - // Keep generic uploads if needed + uploadRouter.POST("", upload.UploadFile) + uploadRouter.GET("/my", upload.ListMyFiles) + uploadRouter.DELETE("/:id", upload.DeleteFile) + uploadRouter.GET("/download/:id", upload.DownloadFile) + uploadRouter.POST("/download/batch", upload.BatchDownloadFiles) } // Config (public) diff --git a/internal/storage/s3.go b/internal/storage/s3.go index f57f0925..e4da1cd0 100644 --- a/internal/storage/s3.go +++ b/internal/storage/s3.go @@ -74,17 +74,52 @@ func init() { log.Printf("[Storage] S3 storage initialized (bucket: %s, prefix: %s, cdn: %s)\n", bucket, keyPrefix, cdnURL) } -func IsEnabled() bool { +var IsEnabledFunc = func() bool { return client != nil } +func IsEnabled() bool { + return IsEnabledFunc() +} + // BuildKey constructs a full S3 object key with the configured prefix. func BuildKey(path string) string { return keyPrefix + path } +var ( + // PutObjectFunc enables mocking S3 uploads in tests. + PutObjectFunc = putObjectDefault + // GetObjectFunc enables mocking S3 downloads in tests. + GetObjectFunc = getObjectDefault + // DeleteObjectFunc enables mocking S3 deletion in tests. + DeleteObjectFunc = deleteObjectDefault +) + +// MockStorage is a test helper to mock S3 storage operations. +// It returns a function that restores original implementations. +func MockStorage( + mockPut func(ctx context.Context, key string, body io.Reader, size int64, contentType string) error, + mockGet func(ctx context.Context, key string) (*ObjectInfo, error), + mockDelete func(ctx context.Context, key string) error, +) func() { + origPut, origGet, origDelete := PutObjectFunc, GetObjectFunc, DeleteObjectFunc + PutObjectFunc = mockPut + GetObjectFunc = mockGet + DeleteObjectFunc = mockDelete + return func() { + PutObjectFunc = origPut + GetObjectFunc = origGet + DeleteObjectFunc = origDelete + } +} + // PutObject uploads a file to S3. func PutObject(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { + return PutObjectFunc(ctx, key, body, size, contentType) +} + +func putObjectDefault(ctx context.Context, key string, body io.Reader, size int64, contentType string) error { ctx, span := otel_trace.Start(ctx, "S3.PutObject", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() @@ -125,6 +160,10 @@ type ObjectInfo struct { // GetObject retrieves a file directly from S3. func GetObject(ctx context.Context, key string) (*ObjectInfo, error) { + return GetObjectFunc(ctx, key) +} + +func getObjectDefault(ctx context.Context, key string) (*ObjectInfo, error) { ctx, span := otel_trace.Start(ctx, "S3.GetObject", trace.WithSpanKind(trace.SpanKindClient)) defer span.End() @@ -206,6 +245,10 @@ func GetObjectViaProxy(ctx context.Context, key string) (*ObjectInfo, error) { // DeleteObject deletes a file from S3. func DeleteObject(ctx context.Context, key string) error { + return DeleteObjectFunc(ctx, key) +} + +func deleteObjectDefault(ctx context.Context, key string) error { ctx, span := otel_trace.Start(ctx, "S3.DeleteObject", trace.WithSpanKind(trace.SpanKindClient)) defer span.End()