feat(课表小组件): syllabus对接AcaSyllabu GET /oapi/timetable/Get——YschOpenCall出站零手写签名,真实ctx=/aca-syllabu(20261013生产401定位,手册/acasyllabu有误);90s TTL缓存+15s失败退避+上限500,不可达降级空数组不500;身份三参全会话推导前端不可传;widget_catalog种子名改「课程表」
build / build-push (push) Successful in 2m7s

This commit is contained in:
chenxing
2026-10-13 12:27:18 +08:00
parent 927d4b14f8
commit 0099e5df2b
3 changed files with 131 additions and 29 deletions
@@ -1,7 +1,10 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
@@ -11,15 +14,21 @@ import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件统一取数(方案 §3.4 已拍板:先按 L2 可溯源 mock)。
* 小组件统一取数。
*
* <p>字段名逐一等于 design/widget-snapshots-old-bsch/*.js 的旧接口口径,接入真实数据源前不外扩;
* 未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
* <p>{@code syllabus}(我的课表)已对接 AcaSyllabu {@code GET /oapi/timetable/Get}(出站走 {@link YschOpenCall},
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
* 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p>
*
* <p>{@code evaluate} / {@code weekplan} 仍为 L2 可溯源 mock(字段名逐一等于
* {@code design/widget-snapshots-old-bsch/*.js} 的旧接口口径);未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
*/
@Service
public class WidgetDataService {
@@ -37,15 +46,36 @@ public class WidgetDataService {
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
private static final int EVALUATE_DEFAULT_COUNT = 5;
/** 课表 oapi 目标名(见 yh.call.targets.acasyllabu) */
private static final String TARGET_ACASYLLABU = "acasyllabu";
/** 课表 oapi 路径(签名按目标 ctx 剥离,故此处不含 /acasyllabu) */
private static final String PATH_TIMETABLE = "/oapi/timetable/Get";
/** 课表缓存时长:课表低频变更,避免每次进桌面都打出站 */
private static final long SYLLABUS_TTL_MS = 90_000L;
/** 课表取数失败后的静默期(短 TTL 空结果,避免下游停摆时持续打出站) */
private static final long SYLLABUS_FAIL_BACKOFF_MS = 15_000L;
/** 课表缓存条目上限,超出时清理过期项 */
private static final int SYLLABUS_CACHE_MAX = 500;
@Autowired
private YschOpenCall openCall;
/** key = userId|roleId|schId → 缓存条目 */
private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>();
/**
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
*
* @return 数据结构由数据键决定;null = 该键未接入
*/
public Object getData(String code, String params, Integer schId) {
public Object getData(String code, String params, Caller caller) {
if (CODE_EVALUATE.equals(code)) return evaluate(params);
if (CODE_SYLLABUS.equals(code)) return syllabus();
if (CODE_WEEKPLAN.equals(code)) return weekplan(params, schId);
if (CODE_SYLLABUS.equals(code)) return syllabus(caller);
if (CODE_WEEKPLAN.equals(code)) return weekplan(params, caller == null ? null : caller.getSchId());
log.warn("[widget] 未配置的数据键:{}", code);
return null;
}
@@ -76,29 +106,41 @@ public class WidgetDataService {
}
/**
* 我的课表:数组,一格一行(节次 CourseTimes × 星期 Weeks)。
* // TODO 接入 oapi: openCall.getList(TARGET_USERBASE, "/oapi/...", null, Vo.class)
* 我的课表:AcaSyllabu {@code GET /oapi/timetable/Get?userid=&roleId=&schId=}(SDK 出站,签名自动)。
* 原样返回 {@code data[]} 班级数组;无可见范围返回 {@code []};不可达 / 验签失败降级空数组(不抛 500)。
*
* <p>身份三参均来自服务端会话推导({@link Caller}),不接受前端传入;roleId&lt;=0 时不透传
* (oapi 侧默认按普通教师处理)。</p>
*/
private List<Map<String, Object>> syllabus() {
List<Map<String, Object>> rows = new ArrayList<>();
addCourse(rows, 1, "第1节", "数学", 1, "周一", "高一(3)班");
addCourse(rows, 1, "第1节", "数学", 2, "周二", "高一(4)班");
addCourse(rows, 1, "第1节", "数学", 4, "周四", "高一(3)班");
addCourse(rows, 2, "第2节", "英语", 2, "周二", "高一(5)班");
addCourse(rows, 2, "第2节", "英语", 3, "周三", "高一(4)班");
addCourse(rows, 2, "第2节", "英语", 5, "周五", "高一(5)班");
addCourse(rows, 3, "第3节", "物理", 1, "周一", "高一(6)班");
addCourse(rows, 3, "第3节", "物理", 3, "周三", "高一(6)班");
addCourse(rows, 3, "第3节", "物理", 4, "周四", "高一(4)班");
addCourse(rows, 4, "第4节", "体育", 3, "周三", "高一(3)班");
addCourse(rows, 4, "第4节", "体育", 5, "周五", "高一(6)班");
return rows;
}
private void addCourse(List<Map<String, Object>> rows, int courseTimes, String courseName, String subName,
int weeks, String weekName, String className) {
rows.add(row("CourseName", courseName, "CourseTimes", courseTimes, "Weeks", weeks,
"weekName", weekName, "SubName", subName, "ClassName", className));
private Object syllabus(Caller caller) {
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
// 缺身份(超管无当前身份等)→ 空数据,不打出站
return Collections.emptyList();
}
String key = caller.getUserId() + "|" + caller.getRoleId() + "|" + caller.getSchId();
long now = System.currentTimeMillis();
CacheEntry hit = syllabusCache.get(key);
if (hit != null && now < hit.expireAt) {
return hit.data;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userid", String.valueOf(caller.getUserId()));
if (caller.getRoleId() != null && caller.getRoleId() > 0) {
query.put("roleId", String.valueOf(caller.getRoleId()));
}
query.put("schId", String.valueOf(caller.getSchId()));
JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class);
Object value = (data != null && data.isArray()) ? data : Collections.emptyList();
cache(key, value, now + SYLLABUS_TTL_MS, now);
return value;
} catch (Exception e) {
log.warn("[widget] 课表 oapi 不可达(降级空)user={} role={} sch={}:{}",
caller.getUserId(), caller.getRoleId(), caller.getSchId(), e.toString());
// 失败退避:短 TTL 空结果,避免下游停摆时每次进桌面都吃一次超时
cache(key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
return Collections.emptyList();
}
}
/**
@@ -128,6 +170,18 @@ public class WidgetDataService {
return result;
}
/** 写缓存并做上限清理(只清过期项,避免无界增长) */
private void cache(String key, Object value, long expireAt, long now) {
if (syllabusCache.size() >= SYLLABUS_CACHE_MAX) {
for (Map.Entry<String, CacheEntry> entry : syllabusCache.entrySet()) {
if (now >= entry.getValue().expireAt) {
syllabusCache.remove(entry.getKey(), entry.getValue());
}
}
}
syllabusCache.put(key, new CacheEntry(value, expireAt));
}
private int intParam(String params, String key, int defaultValue) {
if (params == null || params.isEmpty()) return defaultValue;
for (String pair : params.split("&")) {
@@ -149,4 +203,42 @@ public class WidgetDataService {
}
return map;
}
/** 取数调用方上下文:三参均会话推导,不接受前端传入 */
public static final class Caller {
private final Integer userId;
private final Integer roleId;
private final Integer schId;
public Caller(Integer userId, Integer roleId, Integer schId) {
this.userId = userId;
this.roleId = roleId;
this.schId = schId;
}
public Integer getUserId() {
return userId;
}
public Integer getRoleId() {
return roleId;
}
public Integer getSchId() {
return schId;
}
}
/** 课表缓存条目 */
private static final class CacheEntry {
private final Object data;
private final long expireAt;
private CacheEntry(Object data, long expireAt) {
this.data = data;
this.expireAt = expireAt;
}
}
}
+10
View File
@@ -68,6 +68,16 @@ yh:
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com
base-path: /userbase
call:
# YschCall 直调目标目录(寻址优先级高于 yh.open.services 与 PlatformService 枚举兜底)。
# acasyllabu:课程表 oapi(小组件 syllabus 取数)。base-path 即目标 ctx,
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
targets:
acasyllabu:
base-host: http://base.yunhaixingtong.com
base-path: /aca-syllabu
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
+1 -1
View File
@@ -29,7 +29,7 @@ INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `da
VALUES ('evaluate', '听评课', 1, NULL, NULL, NULL, 1, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('syllabus', '我的课表', 1, NULL, NULL, NULL, 2, 1, NOW());
VALUES ('syllabus', '课程表', 1, NULL, NULL, NULL, 2, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('weekplan', '一周安排', 1, NULL, NULL, NULL, 3, 1, NOW());