# routers/points.py 接口测试文档 ## 文件功能概述 该文件提供积分系统相关的接口,包括: - 查询用户积分余额 - 消费积分下载文件(每次扣除 10 积分) - 管理员为用户添加积分 - 查询积分消费记录 所有接口均需要 Token 认证。积分系统同时支持本地用户(`User` 表,通过 `user_id` 查找)和外部用户(`UserData` 表,通过 `account` 查找)。 路由前缀:`/apiv1`(以 `routers/__init__.py` 中注册为准) 常量:`REQUIRED_POINTS = 10`(每次下载消耗的积分数) --- ## 接口列表 --- ### 1. GET `/apiv1/points/balance` — 获取用户积分余额 **功能说明:** 查询当前登录用户的积分余额。优先查询本地用户表 `User`,若未找到则查询外部用户表 `UserData`。 **是否需要认证:** 是 **请求方式:** GET **请求参数:** 无 **业务逻辑:** 1. 从 `request.state.user` 获取用户信息 2. 通过 `user_info.user_id` + `is_deleted == 0` 查询 `User` 表 3. 若本地用户存在,返回 `user.points`(None 时默认为 0) 4. 若本地用户不存在,通过 `user_info.account` 查询 `UserData` 表的 `accountID` 字段 5. 若外部用户也不存在,返回 404 **注意:** 该接口手动管理数据库会话(`SessionLocal()` + `try/finally/db.close()`),未使用 FastAPI 的 `Depends(get_db)`。 **测试用例:** #### 用例 1:本地用户查询余额 ```json // 请求 GET /apiv1/points/balance token: <本地用户有效Token> // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "points": 100 } } ``` #### 用例 2:外部用户查询余额 ```json // 请求 GET /apiv1/points/balance token: <外部用户有效Token> // 预期响应 (HTTP 200) // 本地 User 表无此用户,回退到 UserData 表查询 { "statusCode": 200, "msg": "success", "data": { "points": 50 } } ``` #### 用例 3:用户不存在(本地和外部均未找到) ```json // 请求 GET /apiv1/points/balance token: // 预期响应 (HTTP 200, 业务码 404) { "statusCode": 404, "msg": "未找到用户数据" } ``` #### 用例 4:积分字段为 null(返回 0) ```json // 请求 GET /apiv1/points/balance token: <用户积分字段为null的Token> // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "points": 0 } } ``` #### 用例 5:未认证 ```json // 请求 GET /apiv1/points/balance // 预期响应 (HTTP 401) { "statusCode": 401, "msg": "未认证" } ``` #### 用例 6:数据库异常 ```json // 预期响应 (HTTP 200, 业务码 500) { "statusCode": 500, "msg": "获取积分余额失败: <具体错误信息>" } ``` --- ### 2. POST `/apiv1/points/consume` — 消费积分下载文件 **功能说明:** 每次消耗 10 积分进行文件下载。扣减积分并记录消费日志。同样支持本地用户和外部用户。 **是否需要认证:** 是 **请求方式:** POST **请求体(JSON):** | 字段 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | file_name | string | 否 | "" | 下载的文件名 | | file_url | string | 否 | "" | 下载的文件 URL | > 注意:该接口直接使用 `request.json()` 解析请求体,未使用 Pydantic 模型。 **业务逻辑:** 1. 解析 JSON 请求体,失败返回 400 2. 查询用户(优先本地 → 外部) 3. 检查积分是否 >= 10 4. 扣减 10 积分,更新数据库 5. 插入 `PointsConsumptionLog` 记录(含 user_id、file_name、file_url、points_consumed、balance_after、created_at、updated_at) 6. 提交事务 **测试用例:** #### 用例 1:本地用户正常消费 ```json // 请求 POST /apiv1/points/consume token: <本地用户Token, 积分>=10> Content-Type: application/json { "file_name": "隧道施工规范.pdf", "file_url": "https://oss.example.com/files/tunnel_spec.pdf" } // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "new_balance": 90, "points_consumed": 10 } } ``` #### 用例 2:外部用户正常消费 ```json // 请求 POST /apiv1/points/consume token: <外部用户Token, 积分>=10> Content-Type: application/json { "file_name": "桥梁设计手册.pdf", "file_url": "https://oss.example.com/files/bridge_manual.pdf" } // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "new_balance": 40, "points_consumed": 10 } } ``` #### 用例 3:积分不足(本地用户) ```json // 请求 POST /apiv1/points/consume token: <本地用户Token, 积分=5> Content-Type: application/json { "file_name": "测试文件.pdf", "file_url": "https://oss.example.com/files/test.pdf" } // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "积分不足,下载需要10积分", "data": { "current_points": 5, "required_points": 10 } } ``` #### 用例 4:积分不足(外部用户) ```json // 请求 POST /apiv1/points/consume token: <外部用户Token, 积分=3> Content-Type: application/json { "file_name": "测试文件.pdf", "file_url": "https://oss.example.com/files/test.pdf" } // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "积分不足,下载需要10积分", "data": { "current_points": 3, "required_points": 10 } } ``` #### 用例 5:用户不存在(本地和外部均无) ```json // 请求 POST /apiv1/points/consume token: Content-Type: application/json { "file_name": "文件.pdf", "file_url": "https://example.com/file.pdf" } // 预期响应 (HTTP 200, 业务码 404) { "statusCode": 404, "msg": "未找到用户数据" } ``` #### 用例 6:JSON 解析失败 ```json // 请求 POST /apiv1/points/consume token: <有效Token> Content-Type: application/json invalid json body // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "JSON解析失败" } ``` #### 用例 7:不传 file_name 和 file_url(使用默认空字符串) ```json // 请求 POST /apiv1/points/consume token: <有效Token, 积分>=10> Content-Type: application/json {} // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "new_balance": 90, "points_consumed": 10 } } ``` > file_name 和 file_url 会被记录为空字符串。 #### 用例 8:未认证 ```json // 请求 POST /apiv1/points/consume // 预期响应 (HTTP 401) { "statusCode": 401, "msg": "未认证" } ``` #### 用例 9:数据库异常(事务回滚) ```json // 预期响应 (HTTP 200, 业务码 500) { "statusCode": 500, "msg": "消费积分失败: <具体错误信息>" } ``` > 注意:异常时代码会执行 `db.rollback()` 回滚事务。 --- ### 3. POST `/apiv1/points/add` — 增加积分(仅管理员) **功能说明:** 管理员为指定用户增加积分。需要管理员权限(`role == "admin"`)。 **是否需要认证:** 是 **是否需要管理员权限:** 是(`user_info.role != "admin"` 时返回 403) **请求方式:** POST **请求体(JSON):** | 字段 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | user_id | int/string | 是 | — | 目标用户 ID | | points | int | 否 | 0 | 增加的积分数(必须 > 0) | | reason | string | 否 | "" | 添加原因(仅记录日志) | **业务逻辑:** 1. 检查管理员权限 2. 解析 JSON,验证 `user_id` 存在且 `points > 0` 3. 通过 `user_id` 查询本地 `User` 表 → 外部 `UserData` 表 4. 增加用户积分并提交 5. 通过 `logger.info` 记录操作日志 **测试用例:** #### 用例 1:管理员为本地用户增加积分 ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json { "user_id": 5, "points": 50, "reason": "活动奖励" } // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "积分添加成功", "data": { "new_balance": 150 } } ``` #### 用例 2:管理员为外部用户增加积分 ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json { "user_id": 10, "points": 30, "reason": "补偿积分" } // 预期响应 (HTTP 200) // 本地 User 表未找到 id=10,回退到 UserData 表 { "statusCode": 200, "msg": "积分添加成功", "data": { "new_balance": 80 } } ``` #### 用例 3:非管理员调用 ```json // 请求 POST /apiv1/points/add token: <普通用户Token> Content-Type: application/json { "user_id": 5, "points": 50, "reason": "测试" } // 预期响应 (HTTP 403) { "statusCode": 403, "msg": "权限不足" } ``` #### 用例 4:user_id 不存在 ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json { "user_id": 99999, "points": 50, "reason": "测试" } // 预期响应 (HTTP 200, 业务码 404) { "statusCode": 404, "msg": "用户不存在" } ``` #### 用例 5:参数错误 — points <= 0 ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json { "user_id": 5, "points": 0, "reason": "测试" } // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "参数错误" } ``` #### 用例 6:参数错误 — 缺少 user_id ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json { "points": 50, "reason": "测试" } // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "参数错误" } ``` #### 用例 7:JSON 解析失败 ```json // 请求 POST /apiv1/points/add token: <管理员Token> Content-Type: application/json invalid json // 预期响应 (HTTP 200, 业务码 400) { "statusCode": 400, "msg": "JSON解析失败" } ``` #### 用例 8:未认证 ```json // 请求 POST /apiv1/points/add // 预期响应 (HTTP 401) { "statusCode": 401, "msg": "未认证" } ``` #### 用例 9:数据库异常 ```json // 预期响应 (HTTP 200, 业务码 500) { "statusCode": 500, "msg": "添加积分失败: <具体错误信息>" } ``` --- ### 4. GET `/apiv1/points/logs` 或 `/apiv1/points/history` — 获取积分消费记录 **功能说明:** 查询当前用户的积分消费记录,支持分页。两个路由路径(`/logs` 和 `/history`)指向同一个处理函数。 **是否需要认证:** 是 **请求方式:** GET **请求参数(Query):** | 字段 | 类型 | 必填 | 默认值 | 说明 | |------|------|------|--------|------| | page | int | 否 | 1 | 页码 | | pageSize | int | 否 | 10 | 每页条数 | **业务逻辑:** 1. 确定用户标识:本地用户使用 `str(user.id)`,外部用户使用 `user_info.account` 2. 通过 `user_identifier` 查询 `PointsConsumptionLog` 表 3. 按 `id` 降序排列(最新的在前) 4. 分页返回 **成功响应字段:** | 字段 | 类型 | 说明 | |------|------|------| | data.list | array | 消费记录列表 | | data.list[].id | int | 记录 ID | | data.list[].file_name | string | 文件名 | | data.list[].file_url | string | 文件 URL | | data.list[].points_consumed | int | 消耗积分数 | | data.list[].balance_after | int | 消费后余额 | | data.list[].created_at | int | 创建时间(Unix 时间戳) | | data.total | int | 总记录数 | | data.page | int | 当前页码 | | data.pageSize | int | 每页条数 | **测试用例:** #### 用例 1:正常查询(默认分页) ```json // 请求 GET /apiv1/points/logs token: <有效Token> // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "list": [ { "id": 10, "file_name": "隧道施工规范.pdf", "file_url": "https://oss.example.com/files/tunnel_spec.pdf", "points_consumed": 10, "balance_after": 90, "created_at": 1700000000 }, { "id": 9, "file_name": "桥梁设计手册.pdf", "file_url": "https://oss.example.com/files/bridge_manual.pdf", "points_consumed": 10, "balance_after": 100, "created_at": 1699999000 } ], "total": 15, "page": 1, "pageSize": 10 } } ``` #### 用例 2:使用 /history 路由别名 ```json // 请求 GET /apiv1/points/history?page=1&pageSize=5 token: <有效Token> // 预期响应 (HTTP 200) // 与 /points/logs 返回格式完全一致 { "statusCode": 200, "msg": "success", "data": { "list": [...], "total": 15, "page": 1, "pageSize": 5 } } ``` #### 用例 3:分页 — 第二页 ```json // 请求 GET /apiv1/points/logs?page=2&pageSize=5 token: <有效Token> // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "list": [ // ... 第 6-10 条记录 ], "total": 15, "page": 2, "pageSize": 5 } } ``` #### 用例 4:无消费记录 ```json // 请求 GET /apiv1/points/logs token: <从未消费积分的用户Token> // 预期响应 (HTTP 200) { "statusCode": 200, "msg": "success", "data": { "list": [], "total": 0, "page": 1, "pageSize": 10 } } ``` #### 用例 5:未认证 ```json // 请求 GET /apiv1/points/logs // 预期响应 (HTTP 401) { "statusCode": 401, "msg": "未认证" } ``` #### 用例 6:数据库异常 ```json // 预期响应 (HTTP 200, 业务码 500) { "statusCode": 500, "msg": "获取消费记录失败: <具体错误信息>" } ``` --- ## 依赖说明 | 依赖项 | 说明 | |--------|------| | `database.SessionLocal` | SQLAlchemy 数据库会话工厂(手动管理,非 Depends 注入) | | `models.total.User` | 本地用户模型(字段:id, points, is_deleted 等) | | `models.user_data.UserData` | 外部用户模型(字段:id, accountID, points 等) | | `models.points.PointsConsumptionLog` | 积分消费日志模型(字段:id, user_id, file_name, file_url, points_consumed, balance_after, created_at, updated_at) | | `utils.logger.logger` | 日志记录器 | | `request.state.user` | 从中间件注入的用户信息(含 user_id, account, role, username 等) | ## 代码备注 1. **双用户体系:** 所有接口都同时支持本地用户(`User` 表)和外部用户(`UserData` 表),查询顺序为先本地后外部。 2. **手动会话管理:** 所有接口使用 `SessionLocal()` 手动创建数据库会话,而非 FastAPI 推荐的 `Depends(get_db)` 依赖注入方式。每个接口都有 `try/except/finally` 结构确保 `db.close()` 被调用。 3. **消费接口的事务性:** `consume` 和 `add` 接口在异常时执行 `db.rollback()`,确保积分扣减和日志插入的原子性。 4. **积分记录的用户标识差异:** 本地用户的 `PointsConsumptionLog.user_id` 存储的是 `str(user.id)`(整数转字符串),外部用户存储的是 `user_info.account`(字符串)。查询记录时也按此逻辑匹配。 5. **`/points/logs` 和 `/points/history` 是同一接口的两个路由路径。** 6. **`add` 接口的权限控制:** 通过 `user_info.role != "admin"` 判断,返回 HTTP 403 状态码(其他接口的认证失败返回 HTTP 401)。 7. **`consume` 和 `add` 接口未使用 Pydantic 模型,** 直接通过 `request.json()` 解析请求体,需要手动处理 JSON 解析失败的情况。 8. **`add` 接口不记录 `PointsConsumptionLog`,** 仅通过 `logger.info` 记录操作日志到文件/控制台。