Compare commits

...
7 Commits
Author SHA1 Message Date
ops 35f7a2eeea fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:30 +00:00
ops d7ea119996 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:01 +00:00
chenxing d2cf718b51 fix(小组件外跳): 目录形态 pc_url 保留尾斜杠,修 /front#/weekplan 少一个斜杠
build / build-push (push) Successful in 1m6s
- 根因:归一化把尾斜杠一并去掉,目录形态基址(.../work-week-plan/front/)被削成 .../front,拼出 .../front#/weekplan(应 .../front/#/weekplan)
- 规则:丢 fragment 后,末段不含 '.'(非文件)视为目录并补回单个斜杠;文件形态(.../index.htm(l))直接拼 #
- 注册表两种形态并存:work-week-plan/aca-syllabus 是目录,notice/coursehomework 是 index.htm(l)
- 新增 WidgetLinkResolverTest 8 例:目录/无斜杠目录/文件/fragment 覆盖/路由归一化/三类降级/不可达短路/查无缓存
2026-10-15 21:02:51 +08:00
chenxing 920408dd0f feat(一周安排小组件): 外跳入口配置(link_app_code/link_path) + 地址按应用注册表解析
build / build-push (push) Successful in 2m24s
- 新增 WidgetLinkResolver:走 SDK OpenApiAppClient.getAppByCode 取 pc_url,归一化(丢 fragment/去尾斜杠)+#+路由;60s TTL+30s 短路;查无/不可达/路由写绝对 URL 一律 null(不猜、不渲染)
- widget_catalog 与 widget_school_setting 各加 link_app_code/link_path(校管可覆盖本校,NULL=沿用全局)
- 生效值合成走 COALESCE;新增「两列必须成对」校验;「全空=清除覆盖」判定由 4 字段扩到 6 字段
- getUserSettings 按需下发 overviewUrl(解析不出则不 put)
- sql/widget_catalog_v3.sql(生产 proj11 已执行两条 ALTER + weekplan 配置)
- seeder 空库给 weekplan 默认 work-week-plan + /weekplan
2026-10-15 20:26:09 +08:00
chenxing 1f31612609 feat(一周安排小组件): weekplan 对接 WorkWeekPlan GET /oapi/workplan/Get,替换原 L2 mock
build / build-push (push) Successful in 56s
- 分发 CODE_WEEKPLAN -> weekplan(caller, params),原样透传单个周对象 data(含 days[7])
- week 为相对本周偏移(0=本周/-1=上周/+1=下周),缺省 0 不向外传(签名 target 最小化);合法范围见 minWeek/maxWeek
- 缓存 key 改 schId|week偏移(全校共享、与身份无关,但不同周内容不同)
- 90s TTL + 15s 失败退避 + 上限 500;缺身份/不可达/验签失败/越界均降级空对象,不抛 500
- 配套 front WeekplanWidget 翻页改版
2026-10-15 16:30:13 +08:00
chenxing ad28149731 chore(oapi): acasyllabu/workplan targets 切开放网关——base-host 指向 open-gateway-service:10013(20261015 切门收口,oapi 统一经网关验签/转发/台账);签名口径不变(网关 canonical 与 starter 逐字节一致);workplan 真实 ctx=/work-week-plan 已网关签名 200 实测并登记注册表(targets 18/routes 25)
build / build-push (push) Successful in 1m4s
2026-10-15 15:25:24 +08:00
chenxing 71c571f884 fix(应用列表): schId 非正数哨兵边界归一——超管 GetUserApp 恢复豁免
build / build-push (push) Successful in 57s
前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),
UserBase 2026-10-12 学校单点开通硬闸门对显式 schoolId 先过 assertSchoolId,
-1 非合法学校主键被拒("学校分组不存在"),且超管豁免(isSuperAdmin bypass)
在校验之后根本走不到。OpenApiAppRemoteServiceImpl 边界归一:schoolId<=0 → null,
交 UserBase 按激活身份/主归属兜底——超管恢复全量豁免,GetAppInfoById 同口径覆盖。
2026-10-13 14:27:39 +08:00
12 changed files with 496 additions and 52 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net options: --network infra-infra-net
steps: steps:
- name: 安装 git / curl(alpine) - name: 安装 git / curl(alpine)
run: apk add --no-cache git curl run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库 # 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库) - name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存) - name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: | run: |
set -e set -e
docker rm -f mavenbuild 2>/dev/null || true # 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
docker run -d --name mavenbuild \ # 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \ --network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \ -v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600 -w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/ docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml" docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package' docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar' docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target" mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar" docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像 - name: 构建并推送镜像
run: | run: |
@@ -27,11 +27,15 @@ public class WidgetCatalogSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class); private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class);
/** {code, name, sort}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应 */ /**
* {code, name, sort, linkAppCode, linkPath}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应。
* linkAppCode/linkPath 为外跳入口默认值(**只存子应用 code + 路由,不存绝对 URL**,域名由应用注册表解析);
* 留空 = 该组件不出外跳入口,管理员可在管理台改。
*/
private static final String[][] SEEDS = { private static final String[][] SEEDS = {
{"evaluate", "听评课", "1"}, {"evaluate", "听评课", "1", null, null},
{"syllabus", "课程表", "2"}, {"syllabus", "课程表", "2", null, null},
{"weekplan", "一周安排", "3"}, {"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
}; };
private static final int COMP_TYPE_BUILTIN = 1; private static final int COMP_TYPE_BUILTIN = 1;
@@ -62,6 +66,8 @@ public class WidgetCatalogSeeder implements CommandLineRunner {
catalog.setCompType(COMP_TYPE_BUILTIN); catalog.setCompType(COMP_TYPE_BUILTIN);
catalog.setScopeType(SCOPE_ALL); catalog.setScopeType(SCOPE_ALL);
catalog.setSort(Integer.valueOf(seed[2])); catalog.setSort(Integer.valueOf(seed[2]));
catalog.setLinkAppCode(seed[3]);
catalog.setLinkPath(seed[4]);
catalog.setEnabled(ENABLED); catalog.setEnabled(ENABLED);
catalog.setUpdateTime(new Date()); catalog.setUpdateTime(new Date());
widgetCatalogRepository.save(catalog); widgetCatalogRepository.save(catalog);
@@ -21,6 +21,7 @@ import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IUserSettingsService; import com.yunhai.userhome.service.IUserSettingsService;
import com.yunhai.userhome.service.IWidgetCatalogService; import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.UsedAppService; import com.yunhai.userhome.service.UsedAppService;
import com.yunhai.userhome.service.WidgetLinkResolver;
import com.yunhai.userhome.utils.EnumUtil; import com.yunhai.userhome.utils.EnumUtil;
import com.yunhai.userhome.utils.FileUtil; import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil; import com.yunhai.userhome.utils.OtherUtil;
@@ -58,6 +59,9 @@ public class UserSettingsController {
@Autowired @Autowired
private UsedAppService usedAppService; private UsedAppService usedAppService;
@Autowired
private WidgetLinkResolver widgetLinkResolver;
@Autowired @Autowired
private OpenApiUserClient openApiUserClient; private OpenApiUserClient openApiUserClient;
@@ -310,6 +314,12 @@ public class UserSettingsController {
map.put("name", catalog.getName()); map.put("name", catalog.getName());
map.put("code", catalog.getCode()); map.put("code", catalog.getCode());
map.put("compType", catalog.getCompType()); map.put("compType", catalog.getCompType());
// 外跳入口:库里只存「子应用 code + 路由」,绝对地址按应用注册表 pc_url 解析后下发;
// 解析不出(未配置 / 查无 / 注册表不可达)则不下发,前端不渲染入口
String overviewUrl = widgetLinkResolver.resolve(catalog.getLinkAppCode(), catalog.getLinkPath());
if (overviewUrl != null) {
map.put("overviewUrl", overviewUrl);
}
return map; return map;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
@@ -64,6 +64,17 @@ public class WidgetCatalog {
@Column(name = "role_alias", length = 255) @Column(name = "role_alias", length = 255)
private String roleAlias; private String roleAlias;
/**
* 外跳目标子应用:应用注册表(UserBase /oapi/app/by-code/{code})的 app_code,如 work-week-plan。
* NULL = 该组件不显示外跳入口。地址由 linkPath + 注册表 pc_url 解析而成,**不落库绝对 URL**。
*/
@Column(name = "link_app_code", length = 32)
private String linkAppCode;
/** 外跳路由(hash 路由,如 /weekplan);拼接口径同消息中心 jumpUrl:归一化 pc_url + "#" + 本路径 */
@Column(name = "link_path", length = 128)
private String linkPath;
@Column(name = "sort", nullable = false) @Column(name = "sort", nullable = false)
private Integer sort = 0; private Integer sort = 0;
@@ -56,6 +56,14 @@ public class WidgetSchoolSetting {
@Column(name = "alias", length = 64) @Column(name = "alias", length = 64)
private String alias; private String alias;
/** 本校覆盖的外跳子应用 code,NULL = 沿用全局 link_app_code */
@Column(name = "link_app_code", length = 32)
private String linkAppCode;
/** 本校覆盖的外跳路由,NULL = 沿用全局 link_path */
@Column(name = "link_path", length = 128)
private String linkPath;
@Column(name = "update_time") @Column(name = "update_time")
private Date updateTime; private Date updateTime;
} }
@@ -9,10 +9,6 @@ import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException; import java.io.UnsupportedEncodingException;
import java.net.URLDecoder; import java.net.URLDecoder;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
@@ -27,7 +23,12 @@ import java.util.concurrent.ConcurrentHashMap;
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]} * 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
* 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p> * 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p>
* *
* <p>{@code evaluate} / {@code weekplan} 仍为 L2 可溯源 mock(字段名逐一等于 * <p>{@code weekplan}(一周安排)已对接 WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(出站走
* {@link YschOpenCall})。全校共享数据、不区分角色,**原样透传**单个周对象 {@code data}(含 {@code days[7]});
* {@code week} 为相对本周偏移(0=本周、-1=上一周、+1=下一周),合法范围见响应 {@code minWeek}/{@code maxWeek};
* 返回内容与身份无关但随周偏移变化,缓存按 {@code schId|week偏移} 双段键。</p>
*
* <p>{@code evaluate} 仍为 L2 可溯源 mock(字段名逐一等于
* {@code design/widget-snapshots-old-bsch/*.js} 的旧接口口径);未知 code 返回 null 由调用方兜空数据,不抛异常。</p> * {@code design/widget-snapshots-old-bsch/*.js} 的旧接口口径);未知 code 返回 null 由调用方兜空数据,不抛异常。</p>
*/ */
@Service @Service
@@ -41,8 +42,6 @@ public class WidgetDataService {
private static final String CODE_WEEKPLAN = "weekplan"; private static final String CODE_WEEKPLAN = "weekplan";
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */ /** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
private static final int EVALUATE_DEFAULT_COUNT = 5; private static final int EVALUATE_DEFAULT_COUNT = 5;
@@ -61,12 +60,30 @@ public class WidgetDataService {
/** 课表缓存条目上限,超出时清理过期项 */ /** 课表缓存条目上限,超出时清理过期项 */
private static final int SYLLABUS_CACHE_MAX = 500; private static final int SYLLABUS_CACHE_MAX = 500;
/** 一周安排 oapi 目标名(见 yh.call.targets.workplan) */
private static final String TARGET_WORKPLAN = "workplan";
/** 一周安排 oapi 路径(签名按目标 ctx 剥离,故此处不含 /work-week-plan) */
private static final String PATH_WORKPLAN = "/oapi/workplan/Get";
/** 一周安排缓存时长:全校共享、低频变更 */
private static final long WEEKPLAN_TTL_MS = 90_000L;
/** 一周安排取数失败后的静默期 */
private static final long WEEKPLAN_FAIL_BACKOFF_MS = 15_000L;
/** 一周安排缓存条目上限,超出时清理过期项 */
private static final int WEEKPLAN_CACHE_MAX = 500;
@Autowired @Autowired
private YschOpenCall openCall; private YschOpenCall openCall;
/** key = userId|roleId|schId → 缓存条目 */ /** key = userId|roleId|schId → 缓存条目 */
private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>();
/** key = schId|weekOffset → 缓存条目(周安排为全校共享数据,与 userid/roleId 无关;但不同周内容不同) */
private final ConcurrentHashMap<String, CacheEntry> weekplanCache = new ConcurrentHashMap<>();
/** /**
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。 * 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
* *
@@ -75,7 +92,7 @@ public class WidgetDataService {
public Object getData(String code, String params, Caller caller) { public Object getData(String code, String params, Caller caller) {
if (CODE_EVALUATE.equals(code)) return evaluate(params); if (CODE_EVALUATE.equals(code)) return evaluate(params);
if (CODE_SYLLABUS.equals(code)) return syllabus(caller); if (CODE_SYLLABUS.equals(code)) return syllabus(caller);
if (CODE_WEEKPLAN.equals(code)) return weekplan(params, caller == null ? null : caller.getSchId()); if (CODE_WEEKPLAN.equals(code)) return weekplan(caller, params);
log.warn("[widget] 未配置的数据键:{}", code); log.warn("[widget] 未配置的数据键:{}", code);
return null; return null;
} }
@@ -132,54 +149,74 @@ public class WidgetDataService {
query.put("schId", String.valueOf(caller.getSchId())); query.put("schId", String.valueOf(caller.getSchId()));
JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class); JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class);
Object value = (data != null && data.isArray()) ? data : Collections.emptyList(); Object value = (data != null && data.isArray()) ? data : Collections.emptyList();
cache(key, value, now + SYLLABUS_TTL_MS, now); cache(syllabusCache, SYLLABUS_CACHE_MAX, key, value, now + SYLLABUS_TTL_MS, now);
return value; return value;
} catch (Exception e) { } catch (Exception e) {
log.warn("[widget] 课表 oapi 不可达(降级空)user={} role={} sch={}:{}", log.warn("[widget] 课表 oapi 不可达(降级空)user={} role={} sch={}:{}",
caller.getUserId(), caller.getRoleId(), caller.getSchId(), e.toString()); caller.getUserId(), caller.getRoleId(), caller.getSchId(), e.toString());
// 失败退避:短 TTL 空结果,避免下游停摆时每次进桌面都吃一次超时 // 失败退避:短 TTL 空结果,避免下游停摆时每次进桌面都吃一次超时
cache(key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now); cache(syllabusCache, SYLLABUS_CACHE_MAX, key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
return Collections.emptyList(); return Collections.emptyList();
} }
} }
/** /**
* 一周安排:{Data:[{date, list:[{contents, times}]}], OtherData:{week, schId}},入参 iweek(-1/0/1)。 * 一周安排:WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(SDK 出站,签名自动)。
* // TODO 接入 oapi: openCall.get(TARGET_USERBASE, "/oapi/...", query, Vo.class) * 全校共享数据(不区分角色),原样透传单个周对象 {@code data}(含 {@code days[7]});
* 缺身份 / 不可达 / 验签失败 / week 越界(oapi {@code code:2},SDK 抛异常)均降级空对象(不抛 500)。
*
* <p>{@code week} 为**相对本周的偏移**(0=本周、-1=上一周、+1=下一周),合法范围由响应
* {@code minWeek}/{@code maxWeek} 提示(前端据此驱动翻页边界);注意与数据字段 {@code data.week}
* (绝对周次)同名不同义。</p>
*
* <p>userid 仅用于 oapi 侧审计、不影响返回内容,故缓存按 {@code schId|week偏移}
* (全校共享、与身份无关,但不同周内容不同,不可共用单键)。</p>
*/ */
private Map<String, Object> weekplan(String params, Integer schId) { private Object weekplan(Caller caller, String params) {
int iweek = Math.max(-1, Math.min(1, intParam(params, "iweek", 0))); if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
LocalDate monday = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY)) // 缺身份(超管无当前身份等)→ 空数据,不打出站
.plusWeeks(iweek); return Collections.emptyMap();
List<Map<String, Object>> data = new ArrayList<>(); }
for (int day = 0; day < 7; day++) { // week = 相对本周偏移(0=本周 / -1=上一周 / +1=下一周),复用 evaluate 同款解析(支持负数)
List<Map<String, Object>> items = new ArrayList<>(); int weekOffset = intParam(params, "week", 0);
if (day == 0) { String key = caller.getSchId() + "|" + weekOffset;
items.add(row("contents", "行政会(三楼会议室)", "times", "09:00-10:00")); long now = System.currentTimeMillis();
} else if (day == 2) { CacheEntry hit = weekplanCache.get(key);
items.add(row("contents", "数学教研组备课磨课", "times", "14:00-15:30")); if (hit != null && now < hit.expireAt) {
} else if (day == 4) { return hit.data;
items.add(row("contents", "家长开放日准备会", "times", "16:00-17:00")); }
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userid", String.valueOf(caller.getUserId()));
query.put("schId", String.valueOf(caller.getSchId()));
if (weekOffset != 0) {
// 0 为缺省值(本周):不传可让本周请求与签名 target 保持最小化
query.put("week", String.valueOf(weekOffset));
} }
data.add(row("date", monday.plusDays(day).format(DATE_FORMAT), "list", items)); JsonNode data = openCall.get(TARGET_WORKPLAN, PATH_WORKPLAN, query, JsonNode.class);
Object value = (data != null && data.isObject()) ? data : Collections.emptyMap();
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, value, now + WEEKPLAN_TTL_MS, now);
return value;
} catch (Exception e) {
log.warn("[widget] 一周安排 oapi 不可达(降级空)sch={} week={}:{}",
caller.getSchId(), weekOffset, e.toString());
// 失败退避:短 TTL 空结果,避免下游停摆时每次翻页都吃一次超时
cache(weekplanCache, WEEKPLAN_CACHE_MAX, key, Collections.emptyMap(), now + WEEKPLAN_FAIL_BACKOFF_MS, now);
return Collections.emptyMap();
} }
Map<String, Object> result = new LinkedHashMap<>();
result.put("Data", data);
// 真实教学周次由数据源下发,mock 固定第 7 周
result.put("OtherData", row("week", 7, "schId", schId));
return result;
} }
/** 写缓存并做上限清理(只清过期项,避免无界增长) */ /** 写缓存并做上限清理(只清过期项,避免无界增长) */
private void cache(String key, Object value, long expireAt, long now) { private void cache(ConcurrentHashMap<String, CacheEntry> store, int max, String key, Object value,
if (syllabusCache.size() >= SYLLABUS_CACHE_MAX) { long expireAt, long now) {
for (Map.Entry<String, CacheEntry> entry : syllabusCache.entrySet()) { if (store.size() >= max) {
for (Map.Entry<String, CacheEntry> entry : store.entrySet()) {
if (now >= entry.getValue().expireAt) { if (now >= entry.getValue().expireAt) {
syllabusCache.remove(entry.getKey(), entry.getValue()); store.remove(entry.getKey(), entry.getValue());
} }
} }
} }
syllabusCache.put(key, new CacheEntry(value, expireAt)); store.put(key, new CacheEntry(value, expireAt));
} }
private int intParam(String params, String key, int defaultValue) { private int intParam(String params, String key, int defaultValue) {
@@ -0,0 +1,150 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件外跳地址解析:{@code app_code} → 应用注册表 {@code pc_url} → 绝对跳转地址。
*
* <p>「地址不写死」落点:库里只存**子应用 code + 路由**,域名一律来自应用注册表
* (UserBase {@code /oapi/app/by-code/{appCode}},经 SDK {@link OpenApiAppClient#getAppByCode} 调用,
* 签名自动)。拼接口径与消息中心 {@code jumpUrl} 一致:{@code pc_url} 归一化
* (丢 fragment、去尾斜杠)后拼 {@code "#" + path}。</p>
*
* <p>降级纪律:未配置 / 查无 pc_url / 注册表不可达 / 路由写成绝对 URL —— 一律返回 {@code null},
* 调用方不渲染跳转入口(pc_url 空就是空,不猜测、不回落)。</p>
*
* <p>缓存与短路:注册表地址 60s TTL(低频变更,避免每次进桌面都打一次 UserBase);查询失败进 30s
* 短路窗口,窗口内沿用旧缓存,避免下游停摆时每次渲染都吃一次连接超时。</p>
*
* <p>TODO 跨服务公共抽取:本实现只解决「小组件这一侧」;消息中心 {@code MsgCenterProxyService}
* 仍保留自己的 by-code 缓存与短路窗口。待平台层({{@code yh-spring-boot-starter}})提供带缓存的
* 地址解析,或 UserBase oapi 直接下发归一化跳转基址后,本类应退化为薄壳。</p>
*/
@Service
public class WidgetLinkResolver {
private static final Logger log = LoggerFactory.getLogger(WidgetLinkResolver.class);
/** 注册表地址缓存时长 */
private static final long PC_URL_TTL_MS = 60_000L;
/** 注册表不可达之后的静默期 */
private static final long PC_URL_DOWN_MS = 30_000L;
/** 注册表应用域客户端(SDK 自动装配,见 yh-openapi-spring-boot-starter 的 OpenApiAutoConfiguration) */
private final OpenApiAppClient appClient;
public WidgetLinkResolver(OpenApiAppClient appClient) {
this.appClient = appClient;
}
/** app_code → pc_url(空串 = 查无,避免脏 code 反复出站) */
private final ConcurrentHashMap<String, String> pcUrlCache = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Long> pcUrlAt = new ConcurrentHashMap<>();
/** 注册表不可达短路截止时间戳 */
private volatile long downUntil;
/**
* 解析外跳地址;无法解析返回 null(调用方不渲染入口)。
*
* @param appCode 应用注册表 app_code,如 {@code work-week-plan}
* @param linkPath 子应用内路由(hash 路由),如 {@code /weekplan};允许写成 {@code #/weekplan} 或 {@code weekplan}
*/
public String resolve(String appCode, String linkPath) {
if (!StringUtils.hasText(appCode) || !StringUtils.hasText(linkPath)) {
return null;
}
String path = normalizePath(linkPath);
if (path == null) {
log.warn("[widget] 外跳路由不合法(应为子应用内相对路由,禁止写绝对 URL):app={} path={}", appCode, linkPath);
return null;
}
String pcUrl = pcUrlOf(appCode.trim());
if (!StringUtils.hasText(pcUrl)) {
return null;
}
String base = pcUrl.trim();
int hash = base.indexOf('#');
if (hash >= 0) {
base = base.substring(0, hash);
}
while (base.endsWith("/")) {
base = base.substring(0, base.length() - 1);
}
if (!StringUtils.hasText(base)) {
return null;
}
// hash 路由只能拼在「基址」之后:目录形态必须补回斜杠(…/front/ + #/weekplan),
// 文件形态直接拼(…/front/index.html + #/list)。缺斜杠会被反代 301 到带斜杠路径,
// 且逐跳有丢 fragment 的风险(注册表两种形态并存:work-week-plan 是目录、notice 是 index.htm)。
int slash = base.lastIndexOf('/');
String lastSegment = slash >= 0 ? base.substring(slash + 1) : base;
if (lastSegment.indexOf('.') < 0) {
base = base + "/";
}
return base + "#" + path;
}
/**
* 路由归一化:去首部 {@code #}、去空白,保证以 {@code /} 开头;
* 绝对 URL(http/https)视为配置错误返回 null(域名只能来自注册表)。
*/
private String normalizePath(String linkPath) {
String path = linkPath.trim();
if (path.startsWith("http://") || path.startsWith("https://")) {
return null;
}
if (path.startsWith("#")) {
path = path.substring(1);
}
if (path.isEmpty()) {
return null;
}
return path.startsWith("/") ? path : "/" + path;
}
/** 注册表 pc_url 查询:命中缓存则返回;查无记空串;不可达走短路窗口(窗口内沿用旧值) */
private String pcUrlOf(String appCode) {
Long at = pcUrlAt.get(appCode);
if (at != null && System.currentTimeMillis() - at < PC_URL_TTL_MS) {
return emptyToNull(pcUrlCache.get(appCode));
}
if (System.currentTimeMillis() < downUntil) {
return emptyToNull(pcUrlCache.get(appCode));
}
try {
JsonNode app = appClient.getAppByCode(appCode);
downUntil = 0L;
String pcUrl = app == null || app.isNull() ? null : text(app, "pcUrl");
pcUrlCache.put(appCode, pcUrl == null ? "" : pcUrl);
pcUrlAt.put(appCode, System.currentTimeMillis());
if (!StringUtils.hasText(pcUrl)) {
log.warn("[widget] 注册表无 pc_url,外跳入口不渲染:app={}", appCode);
}
return pcUrl;
} catch (Exception e) {
downUntil = System.currentTimeMillis() + PC_URL_DOWN_MS;
log.warn("[widget] 应用注册表不可达({}ms 内外跳入口沿用旧值/不渲染):app={} {}",
PC_URL_DOWN_MS, appCode, e.toString());
return emptyToNull(pcUrlCache.get(appCode));
}
}
private String emptyToNull(String value) {
return StringUtils.hasText(value) ? value : null;
}
private String text(JsonNode node, String field) {
JsonNode value = node.get(field);
return value == null || value.isNull() ? null : value.asText();
}
}
@@ -29,6 +29,12 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
// 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
// UserBase 2026-10-12 硬闸门对显式 schoolId 先过 assertSchoolId,-1 会被拒("学校分组不存在"),
// 且超管豁免(isSuperAdmin bypass)在校验之后根本走不到——边界归一为 null 交服务端按激活身份/主归属兜底。
if (schoolId != null && schoolId <= 0) {
schoolId = null;
}
// PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底) // PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId); return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
} }
@@ -124,6 +124,8 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
item.setRoleAlias(catalog.getRoleAlias()); item.setRoleAlias(catalog.getRoleAlias());
item.setSort(effectiveSort(catalog, override)); item.setSort(effectiveSort(catalog, override));
item.setEnabled(effectiveEnabled(catalog, override)); item.setEnabled(effectiveEnabled(catalog, override));
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
item.setLinkPath(effectiveLinkPath(catalog, override));
item.setUpdateTime(resolveUpdateTime(catalog, override)); item.setUpdateTime(resolveUpdateTime(catalog, override));
result.add(item); result.add(item);
} }
@@ -160,6 +162,12 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
if (model.getEnabled() == null) model.setEnabled(ENABLED); if (model.getEnabled() == null) model.setEnabled(ENABLED);
// 内置组件的内容在 front 工程里,库里只登记键 // 内置组件的内容在 front 工程里,库里只登记键
if (model.getCompType() == COMP_TYPE_BUILTIN) model.setContent(null); if (model.getCompType() == COMP_TYPE_BUILTIN) model.setContent(null);
// 外跳目标(可选):只落「子应用 code + 路由」,绝对 URL 由注册表解析,不落库
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
}
model.setUpdateTime(new Date()); model.setUpdateTime(new Date());
widgetCatalogRepository.save(model); widgetCatalogRepository.save(model);
rewritePointSchools(model); rewritePointSchools(model);
@@ -200,11 +208,18 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
} }
if (!StringUtils.hasText(model.getRoleTypes())) model.setRoleTypes(null); if (!StringUtils.hasText(model.getRoleTypes())) model.setRoleTypes(null);
if (!StringUtils.hasText(model.getAlias())) model.setAlias(null); if (!StringUtils.hasText(model.getAlias())) model.setAlias(null);
if (!StringUtils.hasText(model.getLinkAppCode())) model.setLinkAppCode(null);
if (!StringUtils.hasText(model.getLinkPath())) model.setLinkPath(null);
// 外跳必须成对配置:只填 code 或只填路由都是半截配置,直接拒绝
if ((model.getLinkAppCode() == null) != (model.getLinkPath() == null)) {
throw new BizException(ErrorCode.CUSTOM, "外跳配置需同时填「子应用 code」与「路由」,或都留空");
}
WidgetSchoolSetting exist = widgetSchoolSettingRepository WidgetSchoolSetting exist = widgetSchoolSettingRepository
.findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(null); .findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(null);
// 四个可覆盖字段全空 = 清除覆盖(删行,恢复沿用全局) // 可覆盖字段全空 = 清除覆盖(删行,恢复沿用全局)
boolean clearing = model.getEnabled() == null && model.getRoleTypes() == null boolean clearing = model.getEnabled() == null && model.getRoleTypes() == null
&& model.getSortNo() == null && model.getAlias() == null; && model.getSortNo() == null && model.getAlias() == null
&& model.getLinkAppCode() == null && model.getLinkPath() == null;
if (clearing) { if (clearing) {
if (exist != null) widgetSchoolSettingRepository.delete(exist); if (exist != null) widgetSchoolSettingRepository.delete(exist);
return; return;
@@ -275,6 +290,22 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
return catalog.getRoleTypes(); return catalog.getRoleTypes();
} }
/** 外跳子应用 code:校级覆盖优先(COALESCE 口径) */
private String effectiveLinkAppCode(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getLinkAppCode())) {
return override.getLinkAppCode();
}
return catalog.getLinkAppCode();
}
/** 外跳路由:校级覆盖优先(COALESCE 口径) */
private String effectiveLinkPath(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getLinkPath())) {
return override.getLinkPath();
}
return catalog.getLinkPath();
}
private int effectiveEnabled(WidgetCatalog catalog, WidgetSchoolSetting override) { private int effectiveEnabled(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getEnabled() != null) { if (override != null && override.getEnabled() != null) {
return override.getEnabled(); return override.getEnabled();
@@ -311,6 +342,8 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
item.setRoleAlias(catalog.getRoleAlias()); item.setRoleAlias(catalog.getRoleAlias());
item.setSort(effectiveSort(catalog, override)); item.setSort(effectiveSort(catalog, override));
item.setEnabled(effectiveEnabled(catalog, override)); item.setEnabled(effectiveEnabled(catalog, override));
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
item.setLinkPath(effectiveLinkPath(catalog, override));
item.setUpdateTime(resolveUpdateTime(catalog, override)); item.setUpdateTime(resolveUpdateTime(catalog, override));
return item; return item;
} }
+11 -1
View File
@@ -74,10 +74,20 @@ yh:
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。 # 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。 # ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。 # 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
# 网关 target 段(= 目标 ctx)。
targets: targets:
acasyllabu: acasyllabu:
base-host: http://base.yunhaixingtong.com base-host: http://open-gateway-service:10013
base-path: /aca-syllabu base-path: /aca-syllabu
# workplan:一周安排 oapi(小组件 weekplan 取数)。base-path 即目标 ctx,
# 客户端签名按该 ctx 剥离(待签 target = /oapi/workplan/Get?...,不含 /work-week-plan)。
# 真实 ctx = /work-week-plan(20261015 网关签名 200 实测,网关注册表已登记该 target)。
# 本地联调:WorkWeekPlan 在 8110 且无 ctx ⇒ 覆盖 base-host: http://localhost:8110、base-path 留空。
workplan:
base-host: http://open-gateway-service:10013
base-path: /work-week-plan
# env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见 # env-config 前端运行时配置(免登录 GET /api/env-config?target=front,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。 # config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT # 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
@@ -0,0 +1,32 @@
-- ============================================================
-- 小组件外跳配置 v3(「查看概览」等外跳入口)
-- dev 由 JPA ddl-auto=update 依实体自动加列;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
-- 幂等性:MySQL 8 的 ALTER ADD COLUMN 无 IF NOT EXISTS,重复执行报 1060 Duplicate column
-- name,属预期,按需手动跳过即可。
-- ============================================================
-- ---------- 1. widget_catalog 加 2 列(全局默认值) ----------
ALTER TABLE `widget_catalog`
ADD COLUMN `link_app_code` VARCHAR(32) NULL
COMMENT '外跳目标子应用:应用注册表 app_code(UserBase /oapi/app/by-code);NULL=不显示外跳入口',
ADD COLUMN `link_path` VARCHAR(128) NULL
COMMENT '外跳路由(hash 路由,如 /weekplan);地址=注册表 pc_url 归一化 + "#" + 本路径';
-- ---------- 2. widget_school_setting 加 2 列(校级覆盖,NULL=沿用全局) ----------
ALTER TABLE `widget_school_setting`
ADD COLUMN `link_app_code` VARCHAR(32) NULL COMMENT 'NULL=沿用全局 link_app_code',
ADD COLUMN `link_path` VARCHAR(128) NULL COMMENT 'NULL=沿用全局 link_path';
-- 说明:
-- 1) 库里**只存「子应用 code + 路由」**,不落绝对 URL —— 域名一律来自应用注册表
-- (UserBase /oapi/app/by-code/{appCode} → pc_url),换环境/换域名只改注册表。
-- 2) 两列必须成对配置(只填一个会被服务端拒绝),都留空 = 不显示外跳入口。
-- 3) 生效口径:COALESCE(校级覆盖行, 全局值),与 alias / sort / enabled 同口径。
-- ---------- 3.(可选)给「一周安排」开箱配上外跳 ----------
-- 也可在管理台「小组件管理 → 一周安排 → 配置 → 外跳入口」里填,效果等价。
-- UPDATE `widget_catalog` SET `link_app_code` = 'work-week-plan', `link_path` = '/weekplan' WHERE `code` = 'weekplan';
-- 回滚:
-- ALTER TABLE `widget_catalog` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
-- ALTER TABLE `widget_school_setting` DROP COLUMN `link_app_code`, DROP COLUMN `link_path`;
@@ -0,0 +1,132 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.base.OpenApiAppClient;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 小组件外跳地址解析单测:冻结「注册表 pc_url → 绝对跳转地址」的拼接口径。
*
* <p>关键回归点(2026-10-15 生产实锤):注册表两种 pc_url 形态并存——
* 目录形态({@code …/work-week-plan/front/})与文件形态({@code …/notice/front/index.htm})。
* 目录形态**必须保留斜杠**({@code …/front/#/weekplan}),曾因「去尾斜杠」削成 {@code …/front#/weekplan}
* 被反代 301,且有丢 fragment 风险;文件形态直接拼 {@code #} 不能补斜杠。</p>
*/
class WidgetLinkResolverTest {
private static final ObjectMapper MAPPER = new ObjectMapper();
private OpenApiAppClient appClient;
private WidgetLinkResolver resolver;
@BeforeEach
void setUp() {
appClient = mock(OpenApiAppClient.class);
resolver = new WidgetLinkResolver(appClient);
}
private void stubPcUrl(String appCode, String pcUrl) throws Exception {
JsonNode node = pcUrl == null ? MAPPER.readTree("{}") : MAPPER.readTree("{\"pcUrl\":\"" + pcUrl + "\"}");
when(appClient.getAppByCode(eq(appCode))).thenReturn(node);
}
@Test
@DisplayName("目录形态 pc_url:尾斜杠保留,路由拼在斜杠之后")
void dirFormKeepsTrailingSlash() throws Exception {
stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/");
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "/weekplan"));
}
@Test
@DisplayName("目录形态但注册表没写尾斜杠:按目录补回斜杠")
void dirFormWithoutSlashGetsSlash() throws Exception {
stubPcUrl("aca-classroom", "http://base.yunhaixingtong.com/aca-classroom/front");
assertEquals("http://base.yunhaixingtong.com/aca-classroom/front/#/home",
resolver.resolve("aca-classroom", "/home"));
}
@Test
@DisplayName("文件形态 pc_url(index.htm(l)):直接拼 #,不补斜杠")
void fileFormKeepsFile() throws Exception {
stubPcUrl("notice", "http://base.yunhaixingtong.com/notice/front/index.htm");
stubPcUrl("coursehomework", "http://base.yunhaixingtong.com/coursehomework/front/index.html");
assertEquals("http://base.yunhaixingtong.com/notice/front/index.htm#/detail",
resolver.resolve("notice", "/detail"));
assertEquals("http://base.yunhaixingtong.com/coursehomework/front/index.html#/list",
resolver.resolve("coursehomework", "/list"));
}
@Test
@DisplayName("注册表 pc_url 自带 fragment:先丢再按配置路由拼")
void registryFragmentIsDropped() throws Exception {
stubPcUrl("flow-center", "http://base.yunhaixingtong.com/flow-center/front/index.html#/my");
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/weekplan",
resolver.resolve("flow-center", "/weekplan"));
}
@Test
@DisplayName("路由容错:无前导斜杠 / 带 # 前缀都归一化")
void pathIsNormalized() throws Exception {
stubPcUrl("work-week-plan", "http://base.yunhaixingtong.com/work-week-plan/front/");
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "weekplan"));
assertEquals("http://base.yunhaixingtong.com/work-week-plan/front/#/weekplan",
resolver.resolve("work-week-plan", "#/weekplan"));
}
@Test
@DisplayName("降级:未配置 / 查无 pc_url / 路由写绝对 URL 一律 null(不猜、不渲染入口)")
void degradesToNull() throws Exception {
stubPcUrl("no-pcurl", null);
assertNull(resolver.resolve(null, "/x"));
assertNull(resolver.resolve("", "/x"));
assertNull(resolver.resolve("work-week-plan", null));
assertNull(resolver.resolve("work-week-plan", " "));
assertNull(resolver.resolve("no-pcurl", "/x"));
assertNull(resolver.resolve("work-week-plan", "http://evil.example.com/x"));
}
@Test
@DisplayName("注册表不可达:返回 null 且不抛;同一 code 命中缓存只出站一次")
void registryDownAndCache() throws Exception {
when(appClient.getAppByCode(anyString())).thenThrow(new RuntimeException("boom"));
assertNull(resolver.resolve("work-week-plan", "/weekplan"));
assertNull(resolver.resolve("work-week-plan", "/weekplan"));
// 失败进短路窗口:窗口内不再出站
verify(appClient, times(1)).getAppByCode(eq("work-week-plan"));
OpenApiAppClient ok = mock(OpenApiAppClient.class);
stubPcUrlOk(ok);
WidgetLinkResolver cached = new WidgetLinkResolver(ok);
cached.resolve("work-week-plan", "/weekplan");
cached.resolve("work-week-plan", "/weekplan");
verify(ok, times(1)).getAppByCode(eq("work-week-plan"));
}
private void stubPcUrlOk(OpenApiAppClient client) throws Exception {
when(client.getAppByCode(eq("work-week-plan")))
.thenReturn(MAPPER.readTree("{\"pcUrl\":\"http://base.yunhaixingtong.com/work-week-plan/front/\"}"));
}
@Test
@DisplayName("查无 pc_url 也进缓存:脏 code 不重复出站")
void missIsCached() {
when(appClient.getAppByCode(eq("ghost"))).thenReturn(null);
assertNull(resolver.resolve("ghost", "/x"));
assertNull(resolver.resolve("ghost", "/x"));
verify(appClient, times(1)).getAppByCode(eq("ghost"));
}
}