Compare commits
7
Commits
0099e5df2b
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35f7a2eeea | ||
|
|
d7ea119996 | ||
|
|
d2cf718b51 | ||
|
|
920408dd0f | ||
|
|
1f31612609 | ||
|
|
ad28149731 | ||
|
|
71c571f884 |
@@ -25,7 +25,10 @@ jobs:
|
||||
options: --network infra-infra-net
|
||||
steps:
|
||||
- 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 本仓库
|
||||
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
|
||||
@@ -55,18 +58,24 @@ jobs:
|
||||
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
|
||||
run: |
|
||||
set -e
|
||||
docker rm -f mavenbuild 2>/dev/null || true
|
||||
docker run -d --name mavenbuild \
|
||||
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
|
||||
# 命名空间。固定名会在多仓/多条 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 \
|
||||
-v /opt/docker/projman-m2:/root/.m2 \
|
||||
-w /build maven:3.6.3-jdk-8 sleep 3600
|
||||
docker cp "${PWD}/." mavenbuild:/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 mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
|
||||
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 "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
|
||||
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
|
||||
mkdir -p "${PWD}/target"
|
||||
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f mavenbuild
|
||||
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
|
||||
docker rm -f "$BUILD_CONTAINER"
|
||||
|
||||
- name: 构建并推送镜像
|
||||
run: |
|
||||
|
||||
@@ -27,11 +27,15 @@ public class WidgetCatalogSeeder implements CommandLineRunner {
|
||||
|
||||
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 = {
|
||||
{"evaluate", "听评课", "1"},
|
||||
{"syllabus", "课程表", "2"},
|
||||
{"weekplan", "一周安排", "3"},
|
||||
{"evaluate", "听评课", "1", null, null},
|
||||
{"syllabus", "课程表", "2", null, null},
|
||||
{"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
|
||||
};
|
||||
|
||||
private static final int COMP_TYPE_BUILTIN = 1;
|
||||
@@ -62,6 +66,8 @@ public class WidgetCatalogSeeder implements CommandLineRunner {
|
||||
catalog.setCompType(COMP_TYPE_BUILTIN);
|
||||
catalog.setScopeType(SCOPE_ALL);
|
||||
catalog.setSort(Integer.valueOf(seed[2]));
|
||||
catalog.setLinkAppCode(seed[3]);
|
||||
catalog.setLinkPath(seed[4]);
|
||||
catalog.setEnabled(ENABLED);
|
||||
catalog.setUpdateTime(new Date());
|
||||
widgetCatalogRepository.save(catalog);
|
||||
|
||||
@@ -21,6 +21,7 @@ import com.yunhai.userhome.service.IUserCurrentRoleService;
|
||||
import com.yunhai.userhome.service.IUserSettingsService;
|
||||
import com.yunhai.userhome.service.IWidgetCatalogService;
|
||||
import com.yunhai.userhome.service.UsedAppService;
|
||||
import com.yunhai.userhome.service.WidgetLinkResolver;
|
||||
import com.yunhai.userhome.utils.EnumUtil;
|
||||
import com.yunhai.userhome.utils.FileUtil;
|
||||
import com.yunhai.userhome.utils.OtherUtil;
|
||||
@@ -58,6 +59,9 @@ public class UserSettingsController {
|
||||
@Autowired
|
||||
private UsedAppService usedAppService;
|
||||
|
||||
@Autowired
|
||||
private WidgetLinkResolver widgetLinkResolver;
|
||||
|
||||
@Autowired
|
||||
private OpenApiUserClient openApiUserClient;
|
||||
|
||||
@@ -310,6 +314,12 @@ public class UserSettingsController {
|
||||
map.put("name", catalog.getName());
|
||||
map.put("code", catalog.getCode());
|
||||
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;
|
||||
}).collect(Collectors.toList());
|
||||
|
||||
|
||||
@@ -64,6 +64,17 @@ public class WidgetCatalog {
|
||||
@Column(name = "role_alias", length = 255)
|
||||
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)
|
||||
private Integer sort = 0;
|
||||
|
||||
|
||||
@@ -56,6 +56,14 @@ public class WidgetSchoolSetting {
|
||||
@Column(name = "alias", length = 64)
|
||||
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")
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@@ -9,10 +9,6 @@ import org.springframework.stereotype.Service;
|
||||
|
||||
import java.io.UnsupportedEncodingException;
|
||||
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.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
@@ -27,7 +23,12 @@ import java.util.concurrent.ConcurrentHashMap;
|
||||
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
|
||||
* 班级数组(含 {@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>
|
||||
*/
|
||||
@Service
|
||||
@@ -41,8 +42,6 @@ public class WidgetDataService {
|
||||
|
||||
private static final String CODE_WEEKPLAN = "weekplan";
|
||||
|
||||
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
|
||||
|
||||
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 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;
|
||||
|
||||
/** 一周安排 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
|
||||
private YschOpenCall openCall;
|
||||
|
||||
/** key = userId|roleId|schId → 缓存条目 */
|
||||
private final ConcurrentHashMap<String, CacheEntry> syllabusCache = new ConcurrentHashMap<>();
|
||||
|
||||
/** key = schId|weekOffset → 缓存条目(周安排为全校共享数据,与 userid/roleId 无关;但不同周内容不同) */
|
||||
private final ConcurrentHashMap<String, CacheEntry> weekplanCache = new ConcurrentHashMap<>();
|
||||
|
||||
/**
|
||||
* 按数据键取数;params 为 urlencoded 查询串(对齐旧 otherParams 口径)。
|
||||
*
|
||||
@@ -75,7 +92,7 @@ public class WidgetDataService {
|
||||
public Object getData(String code, String params, Caller caller) {
|
||||
if (CODE_EVALUATE.equals(code)) return evaluate(params);
|
||||
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);
|
||||
return null;
|
||||
}
|
||||
@@ -132,54 +149,74 @@ public class WidgetDataService {
|
||||
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);
|
||||
cache(syllabusCache, SYLLABUS_CACHE_MAX, 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);
|
||||
cache(syllabusCache, SYLLABUS_CACHE_MAX, key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
|
||||
return Collections.emptyList();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一周安排:{Data:[{date, list:[{contents, times}]}], OtherData:{week, schId}},入参 iweek(-1/0/1)。
|
||||
* // TODO 接入 oapi: openCall.get(TARGET_USERBASE, "/oapi/...", query, Vo.class)
|
||||
* 一周安排:WorkWeekPlan {@code GET /oapi/workplan/Get?userid=&schId=&week=}(SDK 出站,签名自动)。
|
||||
* 全校共享数据(不区分角色),原样透传单个周对象 {@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) {
|
||||
int iweek = Math.max(-1, Math.min(1, intParam(params, "iweek", 0)));
|
||||
LocalDate monday = LocalDate.now().with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY))
|
||||
.plusWeeks(iweek);
|
||||
List<Map<String, Object>> data = new ArrayList<>();
|
||||
for (int day = 0; day < 7; day++) {
|
||||
List<Map<String, Object>> items = new ArrayList<>();
|
||||
if (day == 0) {
|
||||
items.add(row("contents", "行政会(三楼会议室)", "times", "09:00-10:00"));
|
||||
} else if (day == 2) {
|
||||
items.add(row("contents", "数学教研组备课磨课", "times", "14:00-15:30"));
|
||||
} else if (day == 4) {
|
||||
items.add(row("contents", "家长开放日准备会", "times", "16:00-17:00"));
|
||||
private Object weekplan(Caller caller, String params) {
|
||||
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
|
||||
// 缺身份(超管无当前身份等)→ 空数据,不打出站
|
||||
return Collections.emptyMap();
|
||||
}
|
||||
// week = 相对本周偏移(0=本周 / -1=上一周 / +1=下一周),复用 evaluate 同款解析(支持负数)
|
||||
int weekOffset = intParam(params, "week", 0);
|
||||
String key = caller.getSchId() + "|" + weekOffset;
|
||||
long now = System.currentTimeMillis();
|
||||
CacheEntry hit = weekplanCache.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()));
|
||||
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) {
|
||||
if (syllabusCache.size() >= SYLLABUS_CACHE_MAX) {
|
||||
for (Map.Entry<String, CacheEntry> entry : syllabusCache.entrySet()) {
|
||||
private void cache(ConcurrentHashMap<String, CacheEntry> store, int max, String key, Object value,
|
||||
long expireAt, long now) {
|
||||
if (store.size() >= max) {
|
||||
for (Map.Entry<String, CacheEntry> entry : store.entrySet()) {
|
||||
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) {
|
||||
|
||||
@@ -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) {
|
||||
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 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
|
||||
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
|
||||
}
|
||||
|
||||
@@ -124,6 +124,8 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
item.setRoleAlias(catalog.getRoleAlias());
|
||||
item.setSort(effectiveSort(catalog, override));
|
||||
item.setEnabled(effectiveEnabled(catalog, override));
|
||||
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
|
||||
item.setLinkPath(effectiveLinkPath(catalog, override));
|
||||
item.setUpdateTime(resolveUpdateTime(catalog, override));
|
||||
result.add(item);
|
||||
}
|
||||
@@ -160,6 +162,12 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
if (model.getEnabled() == null) model.setEnabled(ENABLED);
|
||||
// 内置组件的内容在 front 工程里,库里只登记键
|
||||
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());
|
||||
widgetCatalogRepository.save(model);
|
||||
rewritePointSchools(model);
|
||||
@@ -200,11 +208,18 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
}
|
||||
if (!StringUtils.hasText(model.getRoleTypes())) model.setRoleTypes(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
|
||||
.findByWidgetIdAndSchId(model.getWidgetId(), currentSchId).orElse(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 (exist != null) widgetSchoolSettingRepository.delete(exist);
|
||||
return;
|
||||
@@ -275,6 +290,22 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
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) {
|
||||
if (override != null && override.getEnabled() != null) {
|
||||
return override.getEnabled();
|
||||
@@ -311,6 +342,8 @@ public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
|
||||
item.setRoleAlias(catalog.getRoleAlias());
|
||||
item.setSort(effectiveSort(catalog, override));
|
||||
item.setEnabled(effectiveEnabled(catalog, override));
|
||||
item.setLinkAppCode(effectiveLinkAppCode(catalog, override));
|
||||
item.setLinkPath(effectiveLinkPath(catalog, override));
|
||||
item.setUpdateTime(resolveUpdateTime(catalog, override));
|
||||
return item;
|
||||
}
|
||||
|
||||
@@ -74,10 +74,20 @@ yh:
|
||||
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
|
||||
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
|
||||
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
|
||||
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
|
||||
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
|
||||
# 网关 target 段(= 目标 ctx)。
|
||||
targets:
|
||||
acasyllabu:
|
||||
base-host: http://base.yunhaixingtong.com
|
||||
base-host: http://open-gateway-service:10013
|
||||
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,数据源见
|
||||
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
|
||||
# 本地无需配值(前端 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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user