From 920408dd0ff033cbcd7a2ef1b60319a5c6d8de4a Mon Sep 17 00:00:00 2001 From: chenxing Date: Thu, 15 Oct 2026 20:26:09 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=B8=80=E5=91=A8=E5=AE=89=E6=8E=92?= =?UTF-8?q?=E5=B0=8F=E7=BB=84=E4=BB=B6):=20=E5=A4=96=E8=B7=B3=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E9=85=8D=E7=BD=AE(link=5Fapp=5Fcode/link=5Fpath)=20+?= =?UTF-8?q?=20=E5=9C=B0=E5=9D=80=E6=8C=89=E5=BA=94=E7=94=A8=E6=B3=A8?= =?UTF-8?q?=E5=86=8C=E8=A1=A8=E8=A7=A3=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .../userhome/config/WidgetCatalogSeeder.java | 14 +- .../api/UserSettingsController.java | 10 ++ .../yunhai/userhome/entity/WidgetCatalog.java | 11 ++ .../userhome/entity/WidgetSchoolSetting.java | 8 + .../userhome/service/WidgetLinkResolver.java | 139 ++++++++++++++++++ .../impl/WidgetCatalogServiceImpl.java | 37 ++++- src/main/resources/sql/widget_catalog_v3.sql | 32 ++++ 7 files changed, 245 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/yunhai/userhome/service/WidgetLinkResolver.java create mode 100644 src/main/resources/sql/widget_catalog_v3.sql 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`;