diff --git a/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java b/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java
index 95a27c6..3e8b275 100644
--- a/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java
+++ b/src/main/java/com/yunhai/userhome/config/WidgetCatalogSeeder.java
@@ -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);
diff --git a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
index 42765ea..daf0810 100644
--- a/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
+++ b/src/main/java/com/yunhai/userhome/controller/api/UserSettingsController.java
@@ -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());
diff --git a/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java b/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java
index cf8bbff..ec07869 100644
--- a/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java
+++ b/src/main/java/com/yunhai/userhome/entity/WidgetCatalog.java
@@ -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;
diff --git a/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java b/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java
index ca0c1bb..6d24f54 100644
--- a/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java
+++ b/src/main/java/com/yunhai/userhome/entity/WidgetSchoolSetting.java
@@ -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;
}
diff --git a/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java b/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java
new file mode 100644
index 0000000..3b4f560
--- /dev/null
+++ b/src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java
@@ -0,0 +1,139 @@
+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.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.util.StringUtils;
+
+import java.util.concurrent.ConcurrentHashMap;
+
+/**
+ * 小组件外跳地址解析:{@code app_code} → 应用注册表 {@code pc_url} → 绝对跳转地址。
+ *
+ *
「地址不写死」落点:库里只存**子应用 code + 路由**,域名一律来自应用注册表
+ * (UserBase {@code /oapi/app/by-code/{appCode}},经 SDK {@link OpenApiAppClient#getAppByCode} 调用,
+ * 签名自动)。拼接口径与消息中心 {@code jumpUrl} 一致:{@code pc_url} 归一化
+ * (丢 fragment、去尾斜杠)后拼 {@code "#" + path}。
+ *
+ * 降级纪律:未配置 / 查无 pc_url / 注册表不可达 / 路由写成绝对 URL —— 一律返回 {@code null},
+ * 调用方不渲染跳转入口(pc_url 空就是空,不猜测、不回落)。
+ *
+ * 缓存与短路:注册表地址 60s TTL(低频变更,避免每次进桌面都打一次 UserBase);查询失败进 30s
+ * 短路窗口,窗口内沿用旧缓存,避免下游停摆时每次渲染都吃一次连接超时。
+ *
+ * TODO 跨服务公共抽取:本实现只解决「小组件这一侧」;消息中心 {@code MsgCenterProxyService}
+ * 仍保留自己的 by-code 缓存与短路窗口。待平台层({{@code yh-spring-boot-starter}})提供带缓存的
+ * 地址解析,或 UserBase oapi 直接下发归一化跳转基址后,本类应退化为薄壳。
+ */
+@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;
+
+ @Autowired
+ private OpenApiAppClient appClient;
+
+ /** app_code → pc_url(空串 = 查无,避免脏 code 反复出站) */
+ private final ConcurrentHashMap pcUrlCache = new ConcurrentHashMap<>();
+
+ private final ConcurrentHashMap 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;
+ }
+ 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();
+ }
+}
diff --git a/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java b/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java
index da639e2..0318c36 100644
--- a/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java
+++ b/src/main/java/com/yunhai/userhome/service/impl/WidgetCatalogServiceImpl.java
@@ -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;
}
diff --git a/src/main/resources/sql/widget_catalog_v3.sql b/src/main/resources/sql/widget_catalog_v3.sql
new file mode 100644
index 0000000..bfb9ebb
--- /dev/null
+++ b/src/main/resources/sql/widget_catalog_v3.sql
@@ -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`;