欢迎来到 Xiuno BBS

Xiuno BBS 重构记录贴(四)API v1 重构与完善

# API v1 重构与完善  

## Why
当前 API v1 存在 URL 设计反 RESTful(`?action=login`)、分页格式不统一、缺少 access_token/refresh_token 双令牌机制、缺少帖子互动端点(收藏/点赞/举报)、无字段过滤与批量操作、错误响应结构不一致等问题,需要全面重构以达到可直接用于开发的标准。

## What Changes

### **BREAKING** URL 规范化
- `POST /user?action=login` → `POST /auth/login`
- `POST /user?action=register` → `POST /auth/register`
- `POST /notify?action=read` → `PUT /notify/{id}/read`
- `POST /notify?action=read-all` → `PUT /notify/read-all`
- `GET /thread?tid=1` → `GET /thread/1`(统一路径参数)
- `GET /post?pid=1` → `GET /post/1`(统一路径参数)
- `GET /forum?fid=1` → `GET /forum/1`(统一路径参数)

### **BREAKING** 分页响应统一
- 所有列表端点统一返回 `pagination` 对象:`{page, pagesize, total, total_pages}`
- 列表数据统一放在 `list` 字段

### **BREAKING** 错误响应结构增强
- 错误响应增加 `errors` 字段(验证错误时返回字段级详情)
- `code` 字段与 HTTP 状态码对齐(成功为 0,其余与 HTTP 状态码一致)

### Token 机制升级
- 引入 access_token(短期,默认 2 小时)+ refresh_token(长期,默认 30 天)双令牌
- 新增 `POST /auth/refresh` 端点
- 新增 `POST /auth/logout` 端点(撤销 refresh_token)
- `api_token` 表增加 `type` 字段区分 access/refresh

### 新增资源端点
- `POST /thread/{tid}/like` / `DELETE /thread/{tid}/like` — 帖子点赞
- `POST /thread/{tid}/favorite` / `DELETE /thread/{tid}/favorite` — 帖子收藏
- `POST /thread/{tid}/report` — 帖子举报
- `GET /user/{uid}/threads` — 用户帖子列表
- `GET /user/{uid}/posts` — 用户回复列表
- `GET /user/{uid}/favorites` — 用户收藏列表
- `GET /forum/{fid}/threads` — 版块帖子列表(替代 `/thread?fid=`)
- `POST /auth/logout` — 退出登录

### 字段过滤
- 所有 GET 列表/详情端点支持 `fields` 查询参数
- 示例:`GET /thread/1?fields=tid,subject,uid,create_date`

### 批量操作
- `DELETE /thread/batch` — 批量删除帖子(body: `{tids: [1,2,3]}`,需管理员权限)
- `DELETE /post/batch` — 批量删除回复(body: `{pids: [1,2,3]}`,需管理员权限)
- `PUT /thread/batch` — 批量更新帖子类型/置顶/关闭(body: `{tids: [1,2,3], update: {top: 1}}`,需管理员权限)

### API 版本策略
- URL 路径版本:`/api/v1/`
- 响应头包含 `X-API-Version: 1.0`
- 版本变更规则:补丁版本向后兼容,主版本可引入 BREAKING 变更

### 限流增强
- 限流响应增加 `Retry-After` 头
- 认证用户与匿名用户分别限流
- 管理员豁免限流

### 文档自动生成
- `ApiDocService` 从硬编码改为基于注解/配置自动生成
- 新增 `GET /api/v1/openapi.json` 端点返回 OpenAPI 3.0 规范

## Impact
- Affected specs: api-optimization-and-admin-fix(前序 spec,已完成)
- Affected code:
  - `api/v1/bootstrap.php` — 路由分发重构
  - `api/v1/user.php` → 拆分为 `api/v1/auth.php` + `api/v1/user.php`
  - `api/v1/thread.php` — 增加互动端点、路径参数、字段过滤
  - `api/v1/post.php` — 路径参数、字段过滤
  - `api/v1/forum.php` — 增加 `/forum/{fid}/threads` 子资源
  - `api/v1/notify.php` — URL 规范化
  - `api/v1/attach.php` — 路径参数
  - `api/v1/search.php` — 分页格式统一
  - `api/v1/site.php` — 无变更
  - `lib/ApiAuthService.php` — 双令牌机制
  - `lib/ApiResponse.php` — 错误响应结构增强
  - `lib/ApiDocService.php` — 改为自动生成
  - `lib/RateLimitService.php` — 限流增强
  - `service/UserService.php` — 增加 getUserList/getUserCount
  - `service/ThreadService.php` — 增加互动相关方法
  - `docs/refactor/phase3/migration.sql` — 增加 thread_like/thread_favorite/thread_report 表
  - `admin/view/htm/api_doc.htm` — 适配新端点
  - `admin/view/htm/api_debug.htm` — 适配新端点

## ADDED Requirements

### Requirement: RESTful URL 规范
所有 API 端点 SHALL 遵循 RESTful 资源路径设计,禁止使用 `?action=` 查询参数表达操作语义

#### Scenario: 用户登录
- **WHEN** 客户端发送 `POST /api/v1/auth/login`,body 包含 `{email, password}`
- **THEN** 返回 `{code: 0, msg: "ok", data: {access_token, refresh_token, expires_in, user}}`

#### Scenario: 用户注册
- **WHEN** 客户端发送 `POST /api/v1/auth/register`,body 包含 `{email, username, password}`
- **THEN** 返回 `{code: 0, msg: "ok", data: {uid, access_token, refresh_token, expires_in}}`

#### Scenario: 刷新令牌
- **WHEN** 客户端发送 `POST /api/v1/auth/refresh`,body 包含 `{refresh_token}`
- **THEN** 返回 `{code: 0, msg: "ok", data: {access_token, refresh_token, expires_in}}`

#### Scenario: 退出登录
- **WHEN** 客户端发送 `POST /api/v1/auth/logout`,Header 包含 Bearer access_token,body 包含 `{refresh_token}`
- **THEN** 撤销该 refresh_token 及其关联的 access_token,返回 `{code: 0, msg: "ok", data: null}`

#### Scenario: 标记通知已读
- **WHEN** 客户端发送 `PUT /api/v1/notify/{id}/read`
- **THEN** 标记该通知已读,返回 `{code: 0, msg: "ok", data: null}`

#### Scenario: 全部标记已读
- **WHEN** 客户端发送 `PUT /api/v1/notify/read-all`
- **THEN** 标记当前用户所有通知已读,返回 `{code: 0, msg: "ok", data: null}`

### Requirement: 统一分页响应格式
所有列表端点 SHALL 返回统一的分页结构

#### Scenario: 列表响应
- **WHEN** 客户端请求任何列表端点
- **THEN** 响应 data 包含 `{list: [...], pagination: {page, pagesize, total, total_pages}}`

#### Scenario: 分页参数
- **WHEN** 客户端传递 `page` 和 `pagesize` 查询参数
- **THEN** `pagesize` 上限为 100,默认 20;`page` 默认 1

### Requirement: 双令牌认证机制
系统 SHALL 实现 access_token + refresh_token 双令牌机制

#### Scenario: 登录获取令牌
- **WHEN** 用户登录成功
- **THEN** 返回 `access_token`(有效期 2 小时)和 `refresh_token`(有效期 30 天)

#### Scenario: access_token 过期
- **WHEN** access_token 过期后客户端使用 refresh_token 调用 `/auth/refresh`
- **THEN** 返回新的 access_token 和 refresh_token,旧 refresh_token 失效(轮转机制)

#### Scenario: refresh_token 过期
- **WHEN** refresh_token 也过期
- **THEN** 返回 401,客户端需重新登录

#### Scenario: Token 表结构
- **WHEN** 数据库迁移执行
- **THEN** `bbs_api_token` 表增加 `type` 字段(枚举 access/refresh)和 `related_token` 字段(关联 token ID)

### Requirement: 增强错误响应
错误响应 SHALL 包含结构化的错误详情

#### Scenario: 验证错误
- **WHEN** 请求参数验证失败
- **THEN** 返回 `{code: 422, msg: "Validation Error", data: null, errors: [{field: "email", message: "Email is required"}, {field: "password", message: "Password is required"}]}`

#### Scenario: 通用错误
- **WHEN** 请求发生非验证类错误
- **THEN** 返回 `{code: <http_status>, msg: "<描述>", data: null}`

### Requirement: 帖子互动端点
系统 SHALL 提供帖子点赞、收藏、举报端点

#### Scenario: 点赞帖子
- **WHEN** 客户端发送 `POST /api/v1/thread/{tid}/like`(需认证)
- **THEN** 记录点赞,返回 `{code: 0, msg: "ok", data: {liked: true}}`

#### Scenario: 取消点赞
- **WHEN** 客户端发送 `DELETE /api/v1/thread/{tid}/like`(需认证)
- **THEN** 取消点赞,返回 `{code: 0, msg: "ok", data: {liked: false}}`

#### Scenario: 收藏帖子
- **WHEN** 客户端发送 `POST /api/v1/thread/{tid}/favorite`(需认证)
- **THEN** 记录收藏,返回 `{code: 0, msg: "ok", data: {favorited: true}}`

#### Scenario: 取消收藏
- **WHEN** 客户端发送 `DELETE /api/v1/thread/{tid}/favorite`(需认证)
- **THEN** 取消收藏,返回 `{code: 0, msg: "ok", data: {favorited: false}}`

#### Scenario: 举报帖子
- **WHEN** 客户端发送 `POST /api/v1/thread/{tid}/report`(需认证),body 包含 `{reason}`
- **THEN** 记录举报,返回 `{code: 0, msg: "ok", data: null}`

### Requirement: 用户子资源端点
系统 SHALL 提供用户维度的子资源查询

#### Scenario: 用户帖子列表
- **WHEN** 客户端发送 `GET /api/v1/user/{uid}/threads`
- **THEN** 返回该用户的帖子列表(含分页)

#### Scenario: 用户回复列表
- **WHEN** 客户端发送 `GET /api/v1/user/{uid}/posts`
- **THEN** 返回该用户的回复列表(含分页)

#### Scenario: 用户收藏列表
- **WHEN** 客户端发送 `GET /api/v1/user/{uid}/favorites`(需认证,仅可查看自己的)
- **THEN** 返回该用户的收藏帖子列表(含分页)

### Requirement: 版块帖子子资源
系统 SHALL 提供版块下的帖子列表端点

#### Scenario: 版块帖子列表
- **WHEN** 客户端发送 `GET /api/v1/forum/{fid}/threads`
- **THEN** 返回该版块的帖子列表(含分页),支持 `orderby`、`order`、`keyword` 参数

### Requirement: 字段过滤
GET 端点 SHALL 支持 `fields` 查询参数

#### Scenario: 指定返回字段
- **WHEN** 客户端发送 `GET /api/v1/thread/1?fields=tid,subject,uid`
- **THEN** 仅返回 tid、subject、uid 三个字段

#### Scenario: 无效字段名
- **WHEN** 客户端请求的 fields 包含不存在的字段
- **THEN** 忽略无效字段,仅返回有效字段

### Requirement: 批量操作
系统 SHALL 提供管理员批量操作端点

#### Scenario: 批量删除帖子
- **WHEN** 管理员发送 `DELETE /api/v1/thread/batch`,body 包含 `{tids: [1,2,3]}`
- **THEN** 删除指定帖子,返回 `{code: 0, msg: "ok", data: {deleted: 3}}`

#### Scenario: 批量删除回复
- **WHEN** 管理员发送 `DELETE /api/v1/post/batch`,body 包含 `{pids: [1,2,3]}`
- **THEN** 删除指定回复,返回 `{code: 0, msg: "ok", data: {deleted: 3}}`

#### Scenario: 批量更新帖子
- **WHEN** 管理员发送 `PUT /api/v1/thread/batch`,body 包含 `{tids: [1,2,3], update: {top: 1}}`
- **THEN** 批量更新帖子属性,返回 `{code: 0, msg: "ok", data: {updated: 3}}`

#### Scenario: 非管理员批量操作
- **WHEN** 非管理员用户尝试批量操作
- **THEN** 返回 403 Forbidden

### Requirement: 限流增强
限流机制 SHALL 区分认证/匿名用户,并返回标准头

#### Scenario: 限流响应头
- **WHEN** 任何 API 请求
- **THEN** 响应包含 `X-RateLimit-Limit`、`X-RateLimit-Remaining`、`X-RateLimit-Reset` 头

#### Scenario: 触发限流
- **WHEN** 请求超过限流阈值
- **THEN** 返回 429 状态码,包含 `Retry-After` 头

#### Scenario: 认证用户限流
- **WHEN** 认证用户请求 API
- **THEN** 限流阈值高于匿名用户(认证 120/min,匿名 60/min)

#### Scenario: 管理员豁免
- **WHEN** 管理员(gid=1)请求 API
- **THEN** 不受限流限制

### Requirement: OpenAPI 3.0 规范端点
系统 SHALL 提供 OpenAPI 3.0 JSON 规范

#### Scenario: 获取 OpenAPI 规范
- **WHEN** 客户端发送 `GET /api/v1/openapi.json`
- **THEN** 返回符合 OpenAPI 3.0 规范的 JSON 文档

### Requirement: API 版本头
所有 API 响应 SHALL 包含版本信息

#### Scenario: 版本响应头
- **WHEN** 任何 API 请求
- **THEN** 响应包含 `X-API-Version: 1.0` 头

## MODIFIED Requirements

### Requirement: ApiResponse 输出格式
ApiResponse 类 SHALL 支持增强的错误响应结构

- `success()` 方法保持不变:`{code: 0, msg: "ok", data: ...}`
- `error()` 方法增加可选 `errors` 参数:`{code: <int>, msg: <string>, data: null, errors: [...]}`
- 新增 `validationError()` 方法支持字段级错误:`validationError(string $msg, array $errors = [])`

### Requirement: ApiAuthService 令牌管理
ApiAuthService SHALL 支持双令牌机制

- `generateTokens(int $uid): array` — 同时生成 access_token 和 refresh_token
- `validateAccessToken(string $token): ?array` — 验证 access_token
- `validateRefreshToken(string $token): ?array` — 验证 refresh_token
- `refreshTokens(string $refreshToken): ?array` — 轮转刷新令牌
- `revokeTokens(string $refreshToken): bool` — 撤销令牌对
- 保留 `getBearerToken()` 静态方法

### Requirement: bootstrap.php 路由分发
bootstrap.php SHALL 支持更细粒度的路由匹配

- 支持路径参数提取:`/thread/{tid}/like` → `$segments[0]='thread', $segments[1]={tid}, $segments[2]='like'`
- 支持 `auth` 资源路由到 `auth.php`
- 支持 `batch` 子路径路由到对应资源的批量操作

## REMOVED Requirements

### Requirement: ?action= 查询参数路由
**Reason**: 违反 RESTful 设计原则,改为资源路径
**Migration**: 客户端需将 `?action=login` 改为 `POST /auth/login`,`?action=read` 改为 `PUT /notify/{id}/read`

### Requirement: 查询参数获取单个资源
**Reason**: `GET /thread?tid=1` 不符合 REST 规范,应使用路径参数 `GET /thread/1`
**Migration**: 客户端需将 `?tid=1` 改为路径参数 `/thread/1`

---

## 完整端点清单

### 认证 Auth
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| POST | /auth/login | 否 | 登录 |
| POST | /auth/register | 否 | 注册 |
| POST | /auth/refresh | 否 | 刷新令牌 |
| POST | /auth/logout | 是 | 退出登录 |

### 用户 User
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /user | 否 | 用户列表 |
| GET | /user/me | 是 | 当前用户 |
| GET | /user/{uid} | 否 | 用户详情 |
| PUT | /user/{uid} | 是 | 更新用户 |
| GET | /user/{uid}/threads | 否 | 用户帖子 |
| GET | /user/{uid}/posts | 否 | 用户回复 |
| GET | /user/{uid}/favorites | 是 | 用户收藏 |

### 帖子 Thread
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /thread | 否 | 帖子列表 |
| POST | /thread | 是 | 创建帖子 |
| GET | /thread/{tid} | 否 | 帖子详情 |
| PUT | /thread/{tid} | 是 | 更新帖子 |
| DELETE | /thread/{tid} | 是 | 删除帖子 |
| POST | /thread/{tid}/like | 是 | 点赞 |
| DELETE | /thread/{tid}/like | 是 | 取消点赞 |
| POST | /thread/{tid}/favorite | 是 | 收藏 |
| DELETE | /thread/{tid}/favorite | 是 | 取消收藏 |
| POST | /thread/{tid}/report | 是 | 举报 |
| DELETE | /thread/batch | 是(管理员) | 批量删除 |
| PUT | /thread/batch | 是(管理员) | 批量更新 |

### 回复 Post
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /post | 否 | 回复列表 |
| POST | /post | 是 | 创建回复 |
| GET | /post/{pid} | 否 | 回复详情 |
| PUT | /post/{pid} | 是 | 更新回复 |
| DELETE | /post/{pid} | 是 | 删除回复 |
| DELETE | /post/batch | 是(管理员) | 批量删除 |

### 版块 Forum
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /forum | 否 | 版块列表 |
| GET | /forum/{fid} | 否 | 版块详情 |
| GET | /forum/{fid}/threads | 否 | 版块帖子 |

### 附件 Attach
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /attach/{aid} | 否 | 附件详情 |
| POST | /attach | 是 | 上传附件 |
| DELETE | /attach/{aid} | 是 | 删除附件 |

### 通知 Notify
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /notify | 是 | 通知列表 |
| GET | /notify/unread | 是 | 未读数 |
| PUT | /notify/{id}/read | 是 | 标记已读 |
| PUT | /notify/read-all | 是 | 全部已读 |

### 搜索 Search
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /search | 否 | 全文搜索 |

### 站点 Site
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /site | 否 | 站点信息 |
| GET | /site/stats | 否 | 站点统计 |

### 元数据
| 方法 | 路径 | 认证 | 说明 |
|------|------|------|------|
| GET | /openapi.json | 否 | OpenAPI 3.0 规范 |

---

## 数据库变更

```sql
-- 修改 api_token 表:增加 type 和 related_id 字段
ALTER TABLE `bbs_api_token` ADD COLUMN `type` enum('access','refresh') NOT NULL DEFAULT 'access' AFTER `uid`;
ALTER TABLE `bbs_api_token` ADD COLUMN `related_id` bigint(16) unsigned NOT NULL DEFAULT 0 AFTER `type`;
ALTER TABLE `bbs_api_token` ADD INDEX `uid_type` (`uid`, `type`);

-- 帖子点赞表
CREATE TABLE IF NOT EXISTS `bbs_thread_like` (
  `id` bigint(16) unsigned NOT NULL AUTO_INCREMENT,
  `tid` int(11) unsigned NOT NULL DEFAULT 0,
  `uid` int(11) unsigned NOT NULL DEFAULT 0,
  `create_date` int(11) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `tid_uid` (`tid`, `uid`),
  KEY `uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 帖子收藏表
CREATE TABLE IF NOT EXISTS `bbs_thread_favorite` (
  `id` bigint(16) unsigned NOT NULL AUTO_INCREMENT,
  `tid` int(11) unsigned NOT NULL DEFAULT 0,
  `uid` int(11) unsigned NOT NULL DEFAULT 0,
  `create_date` int(11) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  UNIQUE KEY `tid_uid` (`tid`, `uid`),
  KEY `uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

-- 帖子举报表
CREATE TABLE IF NOT EXISTS `bbs_thread_report` (
  `id` bigint(16) unsigned NOT NULL AUTO_INCREMENT,
  `tid` int(11) unsigned NOT NULL DEFAULT 0,
  `uid` int(11) unsigned NOT NULL DEFAULT 0,
  `reason` varchar(500) NOT NULL DEFAULT '',
  `create_date` int(11) unsigned NOT NULL DEFAULT 0,
  PRIMARY KEY (`id`),
  KEY `tid` (`tid`),
  KEY `uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
```
0 0 0
复制成功