docs: oapi课表调用手册、课程表改造计划、重构任务计划
build / build-push (push) Successful in 1m25s

This commit is contained in:
wanghu
2026-10-14 16:15:16 +08:00
parent 1d0d5df9ef
commit d4374e3216
3 changed files with 510 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
# oapi 课表查询接口 · 调用手册
> 服务:AcaSyllabu(课程表) 接口:`GET /oapi/timetable/Get` 更新:2026-10-13
按「用户 + 角色 + 学校」查询其可见范围内所有班级的当前学年学期课程表。身份由调用方显式传入(不依赖浏览器登录态),适用于平台内其他应用(首页卡片、移动端、大屏等)服务端直调。
| 调用方身份 | 可见班级 | 可见科目 |
|---|---|---|
| 管理员(roleId=7)/ 校领导(roleId=2) | 全部班级 | 全部科目 |
| 班主任 | 所带班级(正/副班主任) | 全部科目 |
| 任教老师(roleId=1) | 任教班级 | 仅任教科目(其余科目格清空返回) |
| 学生(roleId=3) | 本人所在班级 | 全部科目 |
- `roleId` 不传时**默认按普通教师(1)**处理。
- 一个用户兼任多重身份(如既是班主任又任教其他班)时,取并集:班主任班给全部科目,任教班只给任教科目。
- 无任何可见范围(如普通教师没有任教记录)时返回空数组 `data: []`,不报错。
---
## 1. 请求
```
GET {base}/oapi/timetable/Get?userid={userId}&roleId={roleId}&schId={schId}
```
- 本地联调:`http://localhost:8109`(无 context-path)
- 生产:`http://{host}/acasyllabu`(context-path `/acasyllabu`,以网关实际地址为准)
| 参数 | 必填 | 说明 |
|---|---|---|
| `userid` | 是 | 平台用户ID。教师=教师ID;学生=学生树里的学生节点ID |
| `roleId` | 否 | 角色:1 教师(默认)、2 校领导、3 学生、7 管理员 |
| `schId` | 是 | 学校ID(数据严格限定在该校范围内) |
## 2. 签名鉴权(必须)
接口在 `/oapi/*` 强制验签下:浏览器/匿名直访一律 **401**,只有注册应用带上签名三件套才能通过。
### 2.1 请求头
| Header | 说明 |
|---|---|
| `X-App-Key` | 调用方应用ID(在服务方 `trusted-apps` 白名单登记的 appKey) |
| `X-Timestamp` | 当前毫秒时间戳。**有效期默认 5 分钟**,过期拒绝,请用当前时间 |
| `X-Signature` | 签名,算法见下 |
### 2.2 签名算法
```
待签串 = 请求方法 + "\n" + target + "\n" + X-Timestamp
target = 请求路径 + "?" + 查询参数(按参数名字典序排列后的 k=v&k=v)
签名 = Base64( HmacSHA256( 待签串, appSecret ) )
```
要点:
- 方法用大写 `GET`;分隔符是换行 `\n`,共两处;
- query 参数**按参数名排序**后再拼(如 `roleId` 在 `schId` 在 `userid` 前),排序后**原样拼接、不做 URL 编码**;
- 密钥即服务方 `trusted-apps` 里登记的该 appKey 对应 secret(找 AcaSyllabu 管理员索取)。
### 2.3 接入新调用方
服务方在 `application.yml`(生产为 Nacos `acasyllabu.yaml`)的 `yh.app-auth.trusted-apps` 下追加一行:
```yaml
yh:
app-auth:
trusted-apps:
"100021": "d3ee380e..." # 既有
"100xxx": "新调用方密钥" # 新增
```
> 生产环境接了中央密钥链(Redis 注册表)后可改走 `trust-registry`,届时以平台注册为准,无需手工维护白名单。
## 3. 响应
外层为平台统一包装:
```json
{ "code": 0, "msg": "", "data": [ …班级数组… ] }
```
`data` 数组每项:
| 字段 | 说明 |
|---|---|
| `classId` / `className` / `gradeName` | 班级ID / 班级名 / 年级名 |
| `headTeacher` | 该用户在本班是否班主任 |
| `viewAllSubjects` | 是否全部科目可见(班主任/管理员/校领导/学生=true,仅任教=false) |
| `subjects` | 任教科目列表(仅 viewAllSubjects=false 时非空) |
| `timetable` | 该班课表,结构同 `TimetableVo`(见下) |
`timetable`:
| 字段 | 说明 |
|---|---|
| `year` / `term` | 课表所属学年学期(term:1 上学期 2 下学期) |
| `photoFileId` / `photoName` / `photoSize` | 照片课表附件(未上传为 null) |
| `slots[]` | 节次数组;`slotNo` 从 1 起,`start`/`end` 为 HH:mm |
| `slots[].subjects` | 固定 7 位,下标 0~6 = 周一~周日,空串=未排课 |
示例(节选):
```json
{
"code": 0, "msg": "",
"data": [
{
"classId": 371, "className": "2班", "gradeName": "高一",
"headTeacher": false, "viewAllSubjects": false,
"subjects": ["语文"],
"timetable": {
"classId": 371, "year": 2026, "term": 1,
"className": "2班", "gradeName": "高一",
"photoFileId": null, "photoName": null, "photoSize": null,
"slots": [
{ "slotNo": 1, "start": "08:20", "end": "09:00",
"subjects": ["语文", "", "", "", "", "", ""] }
],
"viewAllSubjects": false, "mySubjects": ["语文"]
}
}
]
}
```
## 4. 错误情况
| 现象 | 原因 |
|---|---|
| HTTP 401 `missing app signature` | 未带签名三件套 |
| HTTP 401 `invalid signature or expired` | 密钥错误、待签串拼错或时间戳超过 5 分钟 |
| HTTP 401 `unknown appKey` | appKey 未在服务方 `trusted-apps` 登记 |
| HTTP 200 `{"code":6,"msg":"内部服务错误"}` | 缺 `userid`/`schId` 等必填参数 |
| HTTP 200 `code=0, data=[]` | 该身份在该校无可见班级(如无任教记录的教师) |
## 5. 调用示例
### 5.1 Node.js(可直接运行)
```js
const crypto = require('crypto');
const BASE = 'http://localhost:8109';
const APP_KEY = '100xxx';
const APP_SECRET = '你的密钥';
function get(pathAndQuery) {
const q = pathAndQuery.indexOf('?');
const path = q < 0 ? pathAndQuery : pathAndQuery.substring(0, q);
const pairs = q < 0 ? [] : pathAndQuery.substring(q + 1).split('&').sort();
const target = path + (pairs.length ? '?' + pairs.join('&') : '');
const ts = String(Date.now());
const sign = crypto.createHmac('sha256', APP_SECRET)
.update(`GET\n${target}\n${ts}`)
.digest('base64');
return fetch(BASE + pathAndQuery, {
headers: { 'X-App-Key': APP_KEY, 'X-Timestamp': ts, 'X-Signature': sign },
}).then(r => r.json());
}
// 管理员查全校
get('/oapi/timetable/Get?userid=1&roleId=7&schId=3').then(console.log);
```
### 5.2 Java(平台内服务,有 yh-utils-core 依赖)
```java
String pathAndQuery = "/oapi/timetable/Get?userid=1&roleId=7&schId=3";
// query 按参数名排序后拼回
String target = "/oapi/timetable/Get?roleId=7&schId=3&userid=1";
String ts = String.valueOf(System.currentTimeMillis());
String sign = SignUtil.signRequest("GET", target, ts, appSecret); // 平台工具,同款算法
HttpHeaders headers = new HttpHeaders();
headers.set("X-App-Key", appKey);
headers.set("X-Timestamp", ts);
headers.set("X-Signature", sign);
restTemplate.exchange(baseUrl + pathAndQuery, HttpMethod.GET, new HttpEntity<>(headers), String.class);
```
> 平台 Feign 客户端加 `SignFeignInterceptor` 可自动签名,无需手工拼。
### 5.3 curl(Git Bash,签名用 openssl 生成)
```bash
SECRET="你的密钥"
TARGET="/oapi/timetable/Get?roleId=7&schId=3&userid=1" # 参数已按名排序
TS=$(date +%s000)
SIGN=$(printf "GET\n%b\n%b" "$TARGET" "$TS" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -s "http://localhost:8109$TARGET" \
-H "X-App-Key: 100xxx" -H "X-Timestamp: $TS" -H "X-Signature: $SIGN"
```
> 注意 curl 的 URL 必须与参与签名的 target 完全一致(含参数顺序)。
## 6. 本地联调
- 服务方后端:8109 端口(devtools 热重启);本地验签白名单只收了本应用 `100021`(application.yml `trusted-apps`)。
- 现成冒烟脚本:`E:\YHXT\java\gui-test-screenshots\oapi-sign-smoke.js`(用法 `node oapi-sign-smoke.js "/oapi/timetable/Get?userid=1&roleId=7&schId=3"`,密钥内置 100021)。
- 真实数据参考(schId=3):管理员 userid=1 返回全部班级;教师 10008 = 班主任(1班 全部科目) + 任教(2班 仅语文);学生 10066 = 仅 1 班。