Compare commits

...
34 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
chenxing 0099e5df2b feat(课表小组件): syllabus对接AcaSyllabu GET /oapi/timetable/Get——YschOpenCall出站零手写签名,真实ctx=/aca-syllabu(20261013生产401定位,手册/acasyllabu有误);90s TTL缓存+15s失败退避+上限500,不可达降级空数组不500;身份三参全会话推导前端不可传;widget_catalog种子名改「课程表」
build / build-push (push) Successful in 2m7s
2026-10-13 12:27:18 +08:00
chenxing 927d4b14f8 feat(小组件管理改版): 后端校级覆盖三态+目录权/校级权拆分+内容ETag,退役应用管理(AppCategory全家)——配套已上线front 5a19a4e;含getWidgetData传参改Caller{userId,roleId,schId}(数据层WidgetDataService随下一提交);DDL见sql/widget_catalog_v2.sql(生产需手工执行);msg by-code换源已随9370429上线不在本提交 2026-10-13 12:27:12 +08:00
chenxing 9370429455 feat(msg): 消息归属换源页面属主——rewrite 出参 appId/appName 按 jumpApp by-code 解析(缓存+N+1 消除,查无/不可达回落发送方);countByApp 双桶按注册表 appId 合并;类目筛选翻译 app_key+app_code 双值(SDK appKeys -70);任务书:消息归属子应用收口-20261013
build / build-push (push) Successful in 1m22s
2026-10-13 11:39:40 +08:00
chenxing 175dacc9ad 学校应用开通硬闸门:UserHome 贯通 schId 到 SDK getUserApp
build / build-push (push) Failing after 13s
- AppRemoteService/OpenApiAppRemoteServiceImpl 增 getUserApps(user,schoolId)、getAppById(user,appId,schoolId) 重载(原方法委托传 null)
- UserController.getUserApp 透传 schId 并删「schId 暂不参与」过时注释;getAppInfoById 同步带 schId 收窄
2026-10-12 14:23:26 +08:00
chenxing 29d32eed6e feat(小组件内置化): 候选池真源换自建 widget_catalog + 统一取数/内容/管理端四接口
build / build-push (push) Successful in 58s
- widget_catalog:comp_type=1 内容在 front 工程(库里只登记键)、=2 内容存 content,全表无 url 字段 ⇒ 小组件链路彻底不再有「html 地址」;sch_id 只留字段与 visibleToSchool 过滤钩子(v1 全校一致)
- 新接口(真实路径按 /{platform} 展开为 /api/xxx):getWidgetData?code=&params=(统一取数,未知 code 回空数据+msg 不抛 500)、getWidgetContent?code=(仅 comp_type=2 按需取正文,不塞进 getUserSettings 免每次进桌面下发全部 HTML)、GetWidgetCatalogList、SaveWidgetCatalog(content 走 JSON body)
- 管理端鉴权照抄 AppSettingController.checkAdmin(超管 type==1 或 roleId∈{2,5,7},判定标准一字未改;该方法是 private 故复制逻辑),拒绝走 BizException(ErrorCode.FORBIDDEN),按平台信封为 code=3 非 500
- getUserSettings:widgetAppList 改读自建表并增 code/compType、删 url,id/appId 同值以复用既有 widgetAppIds 存取与面板排序链路;saveUserWidget/toggleWidgetShow 未动
- 数据源按拍板走 L2 可溯源 mock(yunhai 9 工程检索课表/听评课零命中),字段逐一等于 design/widget-snapshots-old-bsch 旧接口口径,接入点以 // TODO 接入 oapi: 标注;本期无出站,故未注入 YschOpenCall(零手写签名代码)
- DDL:仓库无 migration 机制(ddl-auto=update),脚本按既有约定落 resources/sql/widget_catalog.sql,dev 自动建表、测试/生产需手工执行(INSERT IGNORE 可重复跑)
2026-10-11 08:47:54 +08:00
chenxing 47f9e3f751 feat(appearance): /oapi/appearance 出参摘除 logoText(子应用 header 定稿只出 logo 图,门户文字走 /api/schLogoText 自有链;init 侧缺失字段自动清 yh-logo-text)——出参恰三字段 {theme,avatar,logoUrl},测试对齐
build / build-push (push) Successful in 2m14s
2026-10-09 17:08:00 +08:00
chenxing 977b5c90a4 fix(壁纸): 删除选中壁纸后白板修复——删除清理按 raw+展示双形态匹配、getUserSettings 悬空引用自愈回落兜底链、管理员选定默认被删池自动跳过、池兜底改取第一张(min id,与用户口径及原注释一致)
build / build-push (push) Successful in 1m56s
2026-10-09 14:53:12 +08:00
chenxing 570b593293 feat(msgcenter): 消息读取改走 SDK msg 读方法并带当前激活身份学校口径(resolveReadSchId→/oapi/identity/current,0/异常降级 null);todo 角标沿用主学校口径不变;直批标读联动带口径(消息按学校隔离 批次3) 2026-10-09 12:23:10 +08:00
chenxing 1f352214f7 feat(壁纸): 三级默认壁纸——sch_settings.wallpaper_url 两级配置 + getUserSettings 兜底链(个人未自选:本校默认→平台默认→池内最新)(LOGO文字与主题JSON化增补)
build / build-push (push) Successful in 1m13s
2026-10-09 11:53:18 +08:00
chenxing f9282574e2 feat(appearance): 主题档存储/传输全面 JSON 化(读侧兼容旧紧凑值)+ LOGO 文字 sch_settings.logo_text 级联读写(LOGO文字与主题JSON化)
build / build-push (push) Successful in 2m1s
2026-10-09 10:35:03 +08:00
chenxing 8b3f8afd77 fix(appearance): 生效值合成统一紧凑编码——级联命中的 ThemeState JSON 转回 theme|0|1|1 形态
build / build-push (push) Successful in 53s
线上验证发现:readThemeWithFallback 返回 ThemeState JSON(schTheme 端点口径),而 oapi 契约/init
收敛只认紧凑编码——层间编码不一致会让学校/平台档被 init 当脏值跳过。合成层补 encodeThemeState
转换(读侧宽松:转换失败降 null 不抛)。
2026-10-09 00:26:40 +08:00
chenxing 0ac521f286 feat(appearance): 开放接口 GET /oapi/appearance(生效值合成)+ 级联下沉 service(oapi 代理轮)
build / build-push (push) Successful in 3m18s
- 新增 controller/oapi/OapiAppearanceController:userId 入参,出参 {theme, avatar, logoUrl} 当前用户生效值
  (个人→学校→平台级联合成,层级知识不外露);出参裸 payload,Map 禁 String
- readThemeWithFallback/readAvatarWithFallback 从 SchSettingsController 下沉 ISchSettingsService(行为等价),
  供 schTheme 读端点与 oapi 合成共享——顺带修复「appearance 无学校→平台级联」审计缺陷 C
- OpenApiAuthEnforceConfiguration:/oapi/* 强制应用签名(无签名 401,对齐 UserMsg/UserBase 同名配置)
- oapi schId 解析走本地 user_current_role(不引 identity 跨仓);超管临时切校角色场景与 /api 版有差,登记偏差
- 单测:生效值合成 3 例(个人优先不触级联/级联命中/脏值+无角色落平台层),60/60 绿
2026-10-08 23:28:40 +08:00
chenxing 54d1b848c3 feat(settings+msgcenter): S7 主题/头像读侧级联 + 消息测试修复与 appKey 收尾
- SchSettingsController:schTheme/schAvatarStyle 读侧加学校→平台(schId=0)级联(readThemeWithFallback/readAvatarWithFallback),
  回传 hitSchId 供设置中心区分「本校已配」与「回落平台」;写侧与接口签名不变
- MsgCenterProxyService:summary.todos 摘除 appKey 死字段(appKey 属后端签名标识不下发)
- MsgCenterProxyServiceTest:两例更新为 appId 口径(字典翻译/字典固定一次出站)+ 补 countByApp 富化/降级、
  appId 查无空页、todos 无 appKey 断言;34/34 绿
2026-10-08 23:08:36 +08:00
chenxing c6acd5d71a 外观偏好异步补齐A:用户外观偏好服务端真源(user_appearance 实体+Appearance 三端点)
- 新实体 user_appearance(per-user 一行,theme_state/avatar_style 紧凑编码,NULL=跟随学校默认)
- 新增 GET appearance 聚合出参 + saveMyTheme/saveMyAvatarStyle 双写端点(登录态 /api/** 链路)
- 聚合读永不抛:个人段/学校段独立降级(identity 跨仓故障→学校段 null),脏值视同未配置
- Logo 择优复用 findLogoList 口径(本校 type=1 → 系统 type=0 → null),URL 拼法对齐 getLogoList
- ThemeCodec 编解码下沉 + resolveThemeSchId/resolveSchId 下沉 SchSettingsService(行为等价重构,供共享)
- 单测:ThemeCodecTest 5 例 + AppearanceServiceTest 9 例(聚合降级/upsert 置 NULL/择优/校验从严)
2026-10-08 18:20:29 +08:00
chenxing 0e57022555 feat(msgcenter): 计数口径改总数 + 出参/筛选全面改用 appId(appKey 不再下发)
build / build-push (push) Successful in 54s
- summary 透传 sysTotal/bizTotal/approvalTotal 分类总数(含已读,UserMsg 新字段)
- 新代理端点 GET /msgCenter/countByApp([{appId, appName, total, unread}])替代 unreadCountByApp:UserMsg /oapi/msg/countByApp + 应用字典富化
- 新增应用字典(UserBase /oapi/app/list 按用户 5min TTL 缓存):appKey↔appId/name 映射,失败沿用旧值并进 30s 短路
- list 筛选参数 appKey→appId(代理翻译为 appKey 下行,字典查无返回空页);行出参 appKey 移除,替换为 appId/appName——appKey 属后端签名标识不再暴露前端
2026-10-08 16:37:15 +08:00
chenxing 18e8e978a6 feat(msgcenter): 按应用未读数代理端点 GET /msgCenter/unreadCountByApp
build / build-push (push) Successful in 1m13s
- 签名代理 UserMsg /oapi/msg/unreadCountByApp({appKey: 未读数},口径同 unreadCount 过滤删除与过期,无未读的 appKey 不出现)
- 消息中心「按应用」清单角标数据源;UserMsg 停机降级空 Map,铃铛不 500
2026-10-08 11:59:52 +08:00
chenxing b621c959cf feat(appicon): 应用图标键下发出栏(YschAppIcons 10-04 轮 P2 后端)
build / build-push (push) Successful in 13m48s
- AppIconKeyService/AppIconEntryVo(新):icon_key/icon_color 资产库键值读取
- UserController:getUserApp 返回体加图标键注入(withIconKeys)
- UserSettingsController:用户设置应用列表注入 iconKey/iconColor(未配置 null 走原 img 回退)
- UsedAppController/UsedAppServiceImpl:常用应用列表同口径注入
依据:根目录《AppIcon资产库落地-执行报告-20261004.md》P2 数据侧/管理端
2026-10-06 20:21:04 +08:00
chenxing 564403b5ca feat(avatar): 学校默认头像样式端点三件套(默认头像轮后端)
- SchSettingsController:GET schAvatarStyle(登录可读,null=未配置)/ POST saveSchAvatarStyle(校管理员)/ POST clearSchAvatarStyle
- SchSettings 实体 + sql/sch_settings_avatar_style.sql:avatar_style 列(DDL 待人工执行)
前端配套=UserHome/front e2c937f(登录写 localStorage[yh-avatar-sch-default] + 设置页 avatar pane)
2026-10-06 20:21:02 +08:00
chenxing a3aa6d917e feat(user): 本人联系方式修改端点 POST /{platform}/user/UpdateContact——登录态取 userId(防替改),YschOpenCall Tier0 直调 UserBase /oapi/user/self/contact(服务目录取址+信封异常透传,零新增配置项);业务错误 DException 冒泡原样包信封,UserBase 不可达收口友好提示;成功返回最新 BoaUser 供前端刷新 store(任务B·UserHome 侧) 2026-10-02 08:54:45 +08:00
chenxing f2166c007b ci: 重触发构建与自动部署——deployMode 已切 auto,重走建档链部署 d71901d(schTheme/主题接口上线)
build / build-push (push) Successful in 2m29s
2026-09-30 14:58:41 +08:00
chenxing d71901db35 feat(theme): 学校默认主题档三接口——schTheme 读 / saveSchThemeState 写 / clearSchTheme 清空(主题设置 S3)
build / build-push (push) Successful in 1m28s
- 契约对齐 YschTheme《主题设置与通道改动》§3.5:读已登录可开放且可空不兜底(与 getSchSettings 的 default-theme 兜底语义刻意区分);写仅学校管理员/超管(权限先例照抄 UserSettingsServiceImpl.findSchWallpaperManageList:type==1 或 OtherUtil.isAdminById(roleId));清空=置空 theme 字段(sch_settings 行还承载 logo 等配置不能删行,置空后读侧 null,效果与删行等价)
- 存储形态:theme_name 列存紧凑四段编码 theme|sideDeep01|gradOn01|sideFollow01(如 teal|0|1|1)——不拆列零 DDL;列宽未确认故不存整份 JSON;读侧只认该格式,旧档名/脏值一律视同未配置(防旧库值意外生效);写入侧四字段校验从严
- schId 解析:超管=显式参数或 0(平台默认层);其余=当前角色学校(UserCurrentRole),兜底主归属
- ISchSettingsService 新增 findBySchIdRaw(无兜底原样读,可空语义专用);既有 saveSchTheme/saveSchLogo/getSchSettings 不动(无其他调用方,影响面零)
2026-09-30 12:52:22 +08:00
chenxing 29f6c30309 feat(msgcenter): 列表筛选参数透传 + summary 聚合 UserMsg 分类计数(消息对接 2026-09-30)
- MessageCenterController.msgCenter/list:可选参数 msgType(0/1/2)/appKey/isRead(0/1)/keyword 透传代理,不传与现状一致(Header 回归无影响)
- MsgCenterProxyService.list:非空才下发 query(旧 UserMsg 收到未知参数会被忽略——UserMsg 部署前线上筛选静默失效,属预期过渡,待人工清单含 UserMsg 部署)
- MsgCenterProxyService.summary:优先调 UserMsg /oapi/msg/summary(新端点:unreadCount/sysUnread/bizUnread/approvalUnread/total/readCount 一次喂饱左栏);旧版无该端点(404/异常)自动降级 unreadCount 单查,分类字段缺省由前端兜底
- 单测:list_filtersPassedThrough(透传+空值不下发)、summary_prefersSummaryEndpoint、summary_legacyUserMsg_fallsBackToUnreadCount;既有 28 用例回归全绿(31 run 0 fail)
2026-09-30 12:37:55 +08:00
chenxing 9f10d8d4eb refactor(msgcenter): 目录键归一 flowcenter→flow-center(D1)——直批出站目标 key 与部署名对齐,私有键 flowcenter-base-url 改名 flow-center-base-url(MsgCenterProperties.flowCenterBaseUrl 同步),注释/常量/测试跟随;私有方法名 getFlowcenter 等为 Java 标识符按惯例保留
build / build-push (push) Successful in 1m40s
2026-09-28 17:22:34 +08:00
chenxing ae4bc5ecf6 feat(msgcenter): 消息跳转收口(C1/C2)——出参增 showJump 判权(authorized-codes 每请求懒加载一次、UserBase 不可达放行、无权 jumpUrl 同置 null 双保险);jump-bases 手工基址表三处退役(字段/yml/兜底分支)+ jumpApp 命中严格二选一不回落发送方 appKey,注册表基址并入尾斜杠归一 2026-09-28 16:52:51 +08:00
chenxing 847f13445d feat(msgcenter): 卡片直批——MsgApprovalProxyService 定位待办办 approve/reject(B3 §2):身份全取会话防代批、bizId=办件 id 匹配 instanceId、0/>1 匹配明确文案、业务拒绝原文透出、不可达降级不阻塞 2026-09-28 13:30:28 +08:00
chenxing 15be1ffabb feat(msgcenter): jumpUrl 基址改走应用注册表——by-code 主查/by-key 兜底取 pc_url(§3.2 归一化丢 fragment 拼 #),jump-bases 降为兜底 + 30s 不可达短路;附 findMessage 共享定位方法(供 B3 直批) 2026-09-28 13:29:17 +08:00
chenxing 7924b2d654 feat(msgcenter): jumpUrl 回落链——jumpApp 优先/bizType 回落/绝对 URL 直通(自指地址 Phase 2 接收侧)+ 本地补 flow.cc/flow.returned/svc_item 三键 2026-09-28 11:54:35 +08:00
56 changed files with 5308 additions and 619 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: |
@@ -10,7 +10,8 @@ import java.util.Map;
/** /**
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路): * 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含), * UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按 bizType 查基址表拼绝对 jumpUrl。 * jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按应用注册表(UserBase by-code/by-key
* 取 pc_url)拼绝对 jumpUrl;手工基址表 jump-bases 已退役(C2)。
*/ */
@Data @Data
@Component @Component
@@ -20,6 +21,12 @@ public class MsgCenterProperties {
/** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */ /** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
private String usermsgBaseUrl = ""; private String usermsgBaseUrl = "";
/**
* flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**);空 = 走服务目录
* flow-center(PlatformService 枚举兜底,key 与部署名对齐)。不可达时直批降级提示,不阻塞消息卡片。
*/
private String flowCenterBaseUrl = "";
/** /**
* 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址, * 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
* 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。 * 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
@@ -29,7 +36,4 @@ public class MsgCenterProperties {
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */ /** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
private Map<String, String> appNames = new LinkedHashMap<>(); private Map<String, String> appNames = new LinkedHashMap<>();
/** bizType → 应用 front 基址(jumpUrl = 基址 + "/#" + jumpPath);生产同主域用相对段如 /flow-center */
private Map<String, String> jumpBases = new LinkedHashMap<>();
} }
@@ -0,0 +1,50 @@
package com.yunhai.userhome.config;
import com.yunhai.platform.appauth.AppAuthFilter;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* 强制开放接口必须带应用签名(对齐 UserMsg/UserBase 同名配置)。<br>
* yh-app-auth 的 {@link AppAuthFilter} 设计为「无签名头则放行」(以兼容浏览器 CAS 流),
* 但 UserHome 的 /oapi/**(外观偏好开放接口)绝不允许匿名访问。本过滤器在 AppAuthFilter 之后运行,
* 校验其写入的请求属性:已验签(属性存在)则放行,否则 401。<br>
* 顺序须晚于 AppAuthFilter(HIGHEST_PRECEDENCE+10),故置为 +11。
*/
@Configuration
public class OpenApiAuthEnforceConfiguration {
@Bean
public FilterRegistrationBean<RequireAppSignFilter> requireAppSignFilter() {
FilterRegistrationBean<RequireAppSignFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new RequireAppSignFilter());
bean.addUrlPatterns("/oapi/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE + 11);
return bean;
}
public static class RequireAppSignFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
if (req.getAttribute(AppAuthFilter.APP_AUTH_ATTR) == null) {
res.sendError(HttpServletResponse.SC_UNAUTHORIZED, "missing app signature");
return;
}
chain.doFilter(request, response);
}
}
}
@@ -0,0 +1,81 @@
package com.yunhai.userhome.config;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.repository.WidgetCatalogRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;
import java.util.Date;
/**
* 小组件内置目录行启动 seeding(幂等)。
*
* <p>背景:种子 INSERT 原先只在 {@code resources/sql/widget_catalog.sql} 里,代码无 seeding,
* 新环境首次启动时候选池恒为空。此处补一条启动期兜底:库为空才写入 3 条内置目录行。</p>
*
* <p>三条种子与 {@code resources/sql/widget_catalog.sql} 用同一份常量(code / name / comp_type / scope_type / enabled),
* 仅补「目录行」,不补内容(内置组件内容在前端工程);{@code WidgetDataService} 白名单不动。</p>
*
* @author chenx
* @date 2026-10-11
*/
@Component
public class WidgetCatalogSeeder implements CommandLineRunner {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogSeeder.class);
/**
* {code, name, sort, linkAppCode, linkPath}:与 resources/sql/widget_catalog.sql 的 INSERT 一一对应。
* linkAppCode/linkPath 为外跳入口默认值(**只存子应用 code + 路由,不存绝对 URL**,域名由应用注册表解析);
* 留空 = 该组件不出外跳入口,管理员可在管理台改。
*/
private static final String[][] SEEDS = {
{"evaluate", "听评课", "1", null, null},
{"syllabus", "课程表", "2", null, null},
{"weekplan", "一周安排", "3", "work-week-plan", "/weekplan"},
};
private static final int COMP_TYPE_BUILTIN = 1;
private static final int SCOPE_ALL = 0;
private static final int ENABLED = 1;
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Override
public void run(String... args) {
try {
// 幂等:库非空即整体跳过,避免管理员下线过的内置组件在重启后被重新种回
if (widgetCatalogRepository.count() > 0) {
log.info("[widget] widget_catalog 非空,跳过内置目录 seeding");
return;
}
for (String[] seed : SEEDS) {
String code = seed[0];
if (widgetCatalogRepository.findByCode(code).isPresent()) {
continue;
}
WidgetCatalog catalog = new WidgetCatalog();
catalog.setCode(code);
catalog.setName(seed[1]);
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);
log.info("[widget] seeding 内置目录行:{}", code);
}
} catch (Exception e) {
// seeding 失败不阻断应用启动(候选池另有管理端入口补录)
log.error("[widget] 内置目录 seeding 失败,跳过", e);
}
}
}
@@ -1,243 +0,0 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.AppCategory;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.service.IAppCategoryService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.InputStreamResource;
import org.springframework.data.domain.Page;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
/**
* 应用设置控制器(自 systemsetting AppSettingController + SysController 迁移,1:1 对齐)。
*
* <p>与原版差异点:写操作(应用 CRUD / UpdateResDate / 模板上传保存)校验调用者
* 为超管(type==1)或校管理员(roleId∈{2,5,7})——原版任何登录用户可写,因新入口仅管理员可见,收紧。
* 查询(分页列表 / FindAppCategory / GetTempList / downloadTemp)登录即可。
*/
@RestController
@RequestMapping(Config.API_PATH)
public class AppSettingController {
@Value("${FILE_PATH:./upload/}")
private String FILE_PATH;
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Autowired
private IAppCategoryService appCategoryService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
// ==================== 应用类别 CRUD ====================
/** 新增/编辑应用(JSON body,1:1 对齐原 SaveAppCategory) */
@PostMapping(value = "SaveAppCategory", produces = "application/json")
public void saveAppCategory(BoaUser authUserWithDept, @RequestBody AppCategory appCategory) {
checkAdmin(authUserWithDept);
if (appCategory.getCDate() == null) appCategory.setCDate(new Date());
if (appCategory.getResDate() == null) appCategory.setResDate(new Date());
appCategory.setCId(authUserWithDept.getId());
appCategoryService.save(appCategory);
}
@PostMapping(value = "DelAppCategory", produces = "application/json")
public void delAppCategory(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.deleteById(id);
}
@GetMapping(value = "GetAppCategoryList")
public Map getAppCategoryList(BoaUser authUserWithDept, Integer page, Integer count,
@RequestParam(value = "roleType") Integer roleType,
@RequestParam(value = "name", required = false) String name) {
Page<AppCategory> data = appCategoryService.findPageList(page, count, roleType, name);
Map map = new HashMap();
map.put("total", data.getTotalElements());
map.put("list", data.getContent());
return map;
}
@GetMapping(value = "FindAppCategory")
public Optional<AppCategory> findAppCategory(BoaUser authUserWithDept, Integer id) {
return appCategoryService.findById(id);
}
@PostMapping(value = "UpdateResDate", produces = "application/json")
public void updateResDate(BoaUser authUserWithDept, Integer id) {
checkAdmin(authUserWithDept);
appCategoryService.updateResDate(id);
}
// ==================== 模板管理(对齐原 SysController) ====================
/** 上传模板压缩包(zip/rar),解压覆盖到 tempUpload/&lt;appModId&gt;/,删除压缩包 */
@PostMapping(value = "tempUpload", produces = "application/json")
public void tempUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file, Integer appModId) {
checkAdmin(authUserWithDept);
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
}
String originalName = file.getOriginalFilename();
String ext = originalName == null ? "" : originalName.substring(originalName.lastIndexOf(".")).toLowerCase(Locale.ROOT);
if (!".zip".equals(ext) && !".rar".equals(ext)) {
throw new BizException(ErrorCode.CUSTOM, "仅支持 zip/rar 格式");
}
// 不走图片 5MB 校验(模板包允许更大,依赖 multipart 配置),存到 tempUpload 子目录
String uploadPath = FILE_PATH + "/tempUpload/";
File fl = new File(uploadPath);
if (!fl.exists()) fl.mkdirs();
String fileName = FileUtil.uploadFile(file, uploadPath);
String filePath = "/upload/tempUpload/" + fileName;
File unzipFiles = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!unzipFiles.exists()) unzipFiles.mkdirs();
else FileUtil.delAllFile(unzipFiles.getPath());
File zipFile = new File(filePath.replace("/upload", FILE_PATH));
try {
FileUtil.zipDecompress(zipFile, unzipFiles);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
} finally {
zipFile.delete();
}
}
/** 模板文件列表(name + 访问 path) */
@GetMapping(value = "GetTempList")
public List<Map> getTempList(BoaUser authUserWithDept, Integer appModId) {
File sourceFile = new File(FILE_PATH + "/tempUpload/" + appModId);
List<Map> result = new ArrayList<>();
if (sourceFile.exists() && sourceFile.isDirectory() && sourceFile.listFiles() != null) {
Arrays.stream(sourceFile.listFiles()).forEach(file -> {
Map map = new HashMap();
map.put("name", file.getName());
map.put("path", FILE_WEB_PATH + "/tempUpload/" + appModId + "/" + file.getName());
result.add(map);
});
}
return result;
}
/** 保存模板文件内容(新增 fileName 路径穿越校验,原版没有;content 走 JSON body 避免 URL 长度限制) */
@PostMapping(value = "SaveTempFile", produces = "application/json")
public void saveTempFile(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
checkAdmin(authUserWithDept);
Object appModIdObj = body.get("appModId");
String fileName = body.get("fileName") == null ? null : body.get("fileName").toString();
String content = body.get("content") == null ? "" : body.get("content").toString();
if (appModIdObj == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少appModId");
}
Integer appModId;
try {
appModId = Integer.parseInt(appModIdObj.toString());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.CUSTOM, "非法appModId");
}
if (fileName == null || fileName.contains("/") || fileName.contains("\\") || fileName.contains("..")) {
throw new BizException(ErrorCode.CUSTOM, "非法文件名");
}
File dir = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!dir.exists()) dir.mkdirs();
File file = new File(FILE_PATH + "/tempUpload/" + appModId + "/" + fileName);
try {
if (!file.exists()) {
file.createNewFile();
}
FileOutputStream fos = new FileOutputStream(file);
fos.write(content.getBytes());
fos.flush();
fos.close();
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "保存失败");
}
}
/** 打包下载模板目录(zip 流式下载,临时 zip 关闭即删) */
@GetMapping(value = "downloadTemp", produces = "application/json;charset=UTF-8")
public ResponseEntity<InputStreamResource> downloadTemp(BoaUser authUserWithDept, Integer appModId, String name) {
File zipFilePath = new File(FILE_PATH + "/tempUpload/" + appModId);
if (!zipFilePath.exists() || !zipFilePath.isDirectory()) {
throw new BizException(ErrorCode.CUSTOM, "模板不存在");
}
String zipName = UUID.randomUUID().toString().substring(0, 8) + ".zip";
File zipFile = new File(FILE_PATH + "/tempUpload/" + zipName);
try {
FileUtil.zipCompress(zipFilePath, zipFile);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
}
String downloadName = (name == null || name.trim().isEmpty()) ? "template" : name;
try {
InputStream inputStream = new FileInputStream(zipFile) {
@Override
public void close() throws IOException {
super.close();
zipFile.delete();
}
};
HttpHeaders headers = new HttpHeaders();
headers.add("Cache-Control", "no-cache, no-store, must-revalidate");
headers.add("Content-Disposition",
"attachment; filename=" + new String((downloadName + ".zip").getBytes("gbk"), "iso8859-1"));
headers.add("Pragma", "no-cache");
headers.add("Expires", "0");
return ResponseEntity.ok().headers(headers)
.contentType(MediaType.parseMediaType("application/octet-stream"))
.body(new InputStreamResource(inputStream));
} catch (IOException e) {
zipFile.delete();
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "下载失败");
}
}
// ==================== 私有方法 ====================
/**
* 写操作权限校验:超管(type==1) 或 校管理员(roleId∈{2,5,7}),其余 403。
*/
private void checkAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
}
@@ -0,0 +1,55 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.service.IAppearanceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 用户外观偏好接口(CAS 登录态,路径 {@code /{platform}/},外观偏好服务端化 2026-10-08)。
* <p>服务端真源 = user_appearance(per-user 一行)+ sch_settings + logo 表;
* 消费方:UserHome/front 设置页双写(saveMyTheme/saveMyAvatarStyle)与各子应用
* yh-theme-init.js 异步补齐段(GET appearance,挂载后 fetch、失败静默回落默认观感)。</p>
* <p>三端点全部登录态、走本应用 /api/** 既有 CAS 链路,不进 oapi/enforce。</p>
*/
@RestController
@RequestMapping(value = Config.API_PATH)
public class AppearanceController {
@Autowired
private IAppearanceService appearanceService;
/**
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl}。
* myTheme/schTheme 为紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01};
* avatarMy/avatarSch 为 {@code text|silhouette};logoUrl 为绝对 URL;
* 查不到/脏值/依赖故障的字段一律 null(init 侧按"null=清键回落默认"收敛)。
*/
@GetMapping("appearance")
public Map<String, Object> appearance(BoaUser authUserWithDept) {
return appearanceService.getAppearance(authUserWithDept);
}
/** 保存用户自选主题档:body {@code {"themeState":"ocean|1|0|1"}};null=跟随学校默认;非法值抛业务异常。 */
@PostMapping("saveMyTheme")
public void saveMyTheme(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
appearanceService.saveMyTheme(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("themeState")));
}
/** 保存用户自选头像样式:body {@code {"avatarStyle":"text"|"silhouette"}};null=跟随学校默认。 */
@PostMapping("saveMyAvatarStyle")
public void saveMyAvatarStyle(BoaUser authUserWithDept, @RequestBody Map<String, Object> body) {
appearanceService.saveMyAvatarStyle(authUserWithDept.getId(), stringOrNull(body == null ? null : body.get("avatarStyle")));
}
private static String stringOrNull(Object v) {
return v == null ? null : String.valueOf(v);
}
}
@@ -2,10 +2,13 @@ package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config; import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.model.MsgHandleRequest;
import com.yunhai.userhome.service.MsgApprovalProxyService;
import com.yunhai.userhome.service.MsgCenterProxyService; import com.yunhai.userhome.service.MsgCenterProxyService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
@@ -24,8 +27,13 @@ import java.util.Map;
public class MessageCenterController { public class MessageCenterController {
private final MsgCenterProxyService proxy; private final MsgCenterProxyService proxy;
private final MsgApprovalProxyService approvalProxy;
/** schId=0 时推断:主学校 → 主归属组(bureau)→ 0(对齐 MessageController 旧口径) */ /**
* 会话主学校(旧口径):<b>只</b>供待办角标(todo)与卡片直批(flow-center 办理人学校)使用——
* 这两条链路口径不变(方案 §8)。<b>消息读取一律不得用它</b>(R2:跨校切换会错),
* 必须用 {@link MsgCenterProxyService#resolveReadSchId}(当前激活身份学校)。
*/
private Integer resolveSchId(BoaUser user) { private Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) { if (user.getSchool() != null) {
return user.getSchool().getId(); return user.getSchool().getId();
@@ -36,19 +44,36 @@ public class MessageCenterController {
return 0; return 0;
} }
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空) */ /** 消息读取口径 schId:当前激活身份所属学校(0/平台/超管与身份链不可达 → null=看全部,D5) */
private Integer msgSchId(BoaUser authUserWithDept) {
return proxy.resolveReadSchId(authUserWithDept.getId());
}
/** 消息分页列表:{total, list[]}(元素含改写后的绝对 jumpUrl;UserMsg 不可达降级空)。
* 可选服务端筛选:msgType 0/1/2、appId(注册表应用 id,代理翻译下行)、isRead 0/1、keyword 标题/内容模糊(不传与现状一致)。 */
@GetMapping("msgCenter/list") @GetMapping("msgCenter/list")
public Map<String, Object> list(BoaUser authUserWithDept, public Map<String, Object> list(BoaUser authUserWithDept,
@RequestParam(value = "page", required = false) Integer page, @RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "count", required = false) Integer count, @RequestParam(value = "count", required = false) Integer count,
@RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread) { @RequestParam(value = "onlyUnread", required = false) Boolean onlyUnread,
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread); @RequestParam(value = "msgType", required = false) Integer msgType,
@RequestParam(value = "appId", required = false) String appId,
@RequestParam(value = "isRead", required = false) Integer isRead,
@RequestParam(value = "keyword", required = false) String keyword) {
return proxy.list(authUserWithDept.getId(), page, count, onlyUnread, msgType, appId, isRead, keyword,
msgSchId(authUserWithDept));
} }
/** 铃铛摘要:未读数 + 各应用待办角标 */ /** 铃铛摘要:未读数 + 各应用待办角标(未读/分类计数按当前身份学校口径;待办角标沿用主学校口径不变) */
@GetMapping("msgCenter/summary") @GetMapping("msgCenter/summary")
public Map<String, Object> summary(BoaUser authUserWithDept) { public Map<String, Object> summary(BoaUser authUserWithDept) {
return proxy.summary(authUserWithDept.getId(), resolveSchId(authUserWithDept)); return proxy.summary(authUserWithDept.getId(), msgSchId(authUserWithDept), resolveSchId(authUserWithDept));
}
/** 按来源应用消息计数(消息中心「按应用」清单角标):[{appId, appName, total, unread}],总数含已读 */
@GetMapping("msgCenter/countByApp")
public java.util.List<java.util.Map<String, Object>> countByApp(BoaUser authUserWithDept) {
return proxy.countByApp(authUserWithDept.getId(), msgSchId(authUserWithDept));
} }
/** /**
@@ -62,15 +87,27 @@ public class MessageCenterController {
return out; return out;
} }
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪) */ /** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
@PostMapping("msgCenter/read") @PostMapping("msgCenter/read")
public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) { public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId)); proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId), msgSchId(authUserWithDept));
} }
/** 全部已读 */ /** 全部已读(按当前身份学校限定:只标本校+平台未读,他校未读不受影响,D6) */
@PostMapping("msgCenter/readAll") @PostMapping("msgCenter/readAll")
public void readAll(BoaUser authUserWithDept) { public void readAll(BoaUser authUserWithDept) {
proxy.readAll(authUserWithDept.getId()); proxy.readAll(authUserWithDept.getId(), msgSchId(authUserWithDept));
}
/**
* 卡片直批(B3 §2):同意/拒绝消息对应的审批待办。
* 红线:operatorId/schId/operatorName 一律取当前会话,绝不接受前端传参(防越权代批)。
*
* @return {ok, message}——ok=false 时 message 为可直接展示的文案(已处理/多节点/业务拒绝/不可达降级)
*/
@PostMapping("msgCenter/handle")
public Map<String, Object> handle(BoaUser authUserWithDept, @RequestBody MsgHandleRequest request) {
return approvalProxy.handle(authUserWithDept.getId(), resolveSchId(authUserWithDept),
authUserWithDept.getName(), request.getMsgId(), request.getAction(), request.getOpinion());
} }
} }
@@ -1,9 +1,14 @@
package com.yunhai.userhome.controller.api; package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config; import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.SchSettings; import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.OtherUtil;
import com.yunhai.userhome.utils.ThemeCodec;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -12,12 +17,29 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.Date; import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional; import java.util.Optional;
/** /**
* 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。 * 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig); * <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
* saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p> * saveSchTheme/saveSchLogo 写本地 sch_settings 表。</p>
* <p>主题设置轮(2026-09-30)新增学校默认主题档三接口(契约见 YschTheme《主题设置与通道改动-执行方案》§3.5):
* schTheme 读(已登录可读,当时"可空不兜底"——S7 起改为学校→平台级联,见文末说明);
* saveSchThemeState 写(仅学校管理员/超管);clearSchTheme 清空(置空 theme 字段——sch_settings 行还承载
* logo 等字段不能删行,置空后读侧返回 null,效果等同"未配置")。存储形态(2026-10-09 JSON 化):
* theme_name 列存规范化 ThemeState JSON 串(固定字段序;历史紧凑四段编码读侧兼容自动转 JSON;
* 旧格式值/非法值读侧一律视同未配置)。</p>
* <p>默认头像轮(2026-09-30)同构新增学校默认头像样式三接口:schAvatarStyle 读(可空不兜底);
* saveSchAvatarStyle 写(仅学校管理员/超管,值只接受 text|silhouette);clearSchAvatarStyle 清空(置空
* avatar_style 字段)。</p>
* <p>外观偏好轮(2026-10-08):编解码下沉 {@link ThemeCodec}、schId 解析下沉
* {@link ISchSettingsService#resolveThemeSchId}(行为等价重构,供 AppearanceController 共享)。</p>
* <p>三级重构 S7(2026-10-08):{@code schTheme}/{@code schAvatarStyle} 读侧加"学校 → 平台默认(schId=0)"级联
* (实现已随 oapi 代理轮下沉 {@link ISchSettingsService#readThemeWithFallback}/{@link ISchSettingsService#readAvatarWithFallback})
* ——本校未配置或脏值时回落平台档,平台也无配置才返回 null。写侧与接口签名、响应体结构均未变;
* {@code getSchSettings}/{@code findBySchId} 的"合成默认对象"兜底语义也未变。</p>
*/ */
@RestController @RestController
@RequestMapping(value = Config.API_PATH) @RequestMapping(value = Config.API_PATH)
@@ -26,20 +48,175 @@ public class SchSettingsController {
@Autowired @Autowired
private ISchSettingsService schSettingsService; private ISchSettingsService schSettingsService;
private Integer resolveSchId(BoaUser user) { @Autowired
if (user.getSchool() != null) { private IUserCurrentRoleService userCurrentRoleService;
return user.getSchool().getId();
/** 主题档写权限:超管或学校管理员(先例:UserSettingsServiceImpl.findSchWallpaperManageList) */
private void requireThemeAdmin(BoaUser user) {
boolean superAdmin = user.getType() != null && user.getType() == 1;
if (!superAdmin) {
Optional<UserCurrentRole> currentRole = userCurrentRoleService.findById(user.getId());
if (!currentRole.isPresent() || !OtherUtil.isAdminById(currentRole.get().getRoleId())) {
throw new DException("仅学校管理员可配置本校默认主题");
} }
if (user.getBureau() != null) {
return user.getBureau().getId();
} }
return 0; }
/** 学校默认头像样式(默认头像轮 2026-09-30,已登录可读):{avatarStyle: "text"|"silhouette"|null, schId}。
* 未配置/脏值 → 回落平台默认层(级联见 {@link ISchSettingsService#readAvatarWithFallback});两级都无 →
* avatarStyle=null(前端"不写键、不报错")。 */
@GetMapping("schAvatarStyle")
public Map<String, Object> schAvatarStyle(BoaUser authUserWithDept) {
ISchSettingsService.LayerValue hit =
schSettingsService.readAvatarWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("avatarStyle", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
/** 保存学校默认头像样式(仅学校管理员/超管):值只接受 "text"/"silhouette",非法抛 DException。 */
@PostMapping("saveSchAvatarStyle")
public void saveSchAvatarStyle(BoaUser authUserWithDept,
@RequestParam("avatarStyle") String avatarStyle,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
if (!ThemeCodec.isAvatarStyle(avatarStyle)) {
throw new DException("头像样式不合法: " + avatarStyle);
}
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
schSettings.setCDate(new Date());
schSettings.setSchId(targetSchId);
schSettings.setCId(authUserWithDept.getId());
schSettings.setAvatarStyle(avatarStyle);
schSettingsService.save(schSettings);
}
/** 清空学校默认头像样式(仅管理员):置空 avatar_style 字段(行还承载 logo/theme 等配置不能删行;置空后读侧回落平台层,平台也无则 null)。 */
@PostMapping("clearSchAvatarStyle")
public void clearSchAvatarStyle(BoaUser authUserWithDept,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
if (!existing.isPresent()) return;
SchSettings schSettings = existing.get();
schSettings.setAvatarStyle(null);
schSettingsService.save(schSettings);
}
/** 学校默认主题档(已登录可读):{themeState: "&lt;ThemeState JSON&gt;" | null, schId};schId 为实际命中层
* (见 {@link ISchSettingsService.LayerValue})。本校未配置/脏值 → 回落平台默认层
* (级联见 {@link ISchSettingsService#readThemeWithFallback});两级都无 → themeState=null
* (前端"不写键、不报错")。
*/
@GetMapping("schTheme")
public Map<String, Object> schTheme(BoaUser authUserWithDept) {
ISchSettingsService.LayerValue hit =
schSettingsService.readThemeWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("themeState", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
/** 保存学校默认主题档(仅学校管理员/超管):themeState 为整份 ThemeState JSON,规范化后原形态存 theme_name(2026-10-09 JSON 化)。 */
@PostMapping("saveSchThemeState")
public void saveSchThemeState(BoaUser authUserWithDept,
@RequestParam("themeState") String themeState,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
String encoded = ThemeCodec.encodeThemeState(themeState);
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
schSettings.setCDate(new Date());
schSettings.setSchId(targetSchId);
schSettings.setCId(authUserWithDept.getId());
schSettings.setThemeName(encoded);
schSettingsService.save(schSettings);
}
/** 清空学校默认主题(仅管理员):置空 theme 字段(行还承载 logo 等配置不能删行;置空后读侧返回 null)。 */
@PostMapping("clearSchTheme")
public void clearSchTheme(BoaUser authUserWithDept,
@RequestParam(name = "schId", required = false) Integer schId) {
requireThemeAdmin(authUserWithDept);
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, schId);
Optional<SchSettings> existing = schSettingsService.findBySchIdRaw(targetSchId);
if (!existing.isPresent()) return;
SchSettings schSettings = existing.get();
schSettings.setThemeName(null);
schSettingsService.save(schSettings);
}
// ===== LOGO 文字(平台/学校名称,LOGO 文字轮 2026-10-09):与主题三件套同模式 =====
/** LOGO 文字读(已登录可读):{logoText: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
@GetMapping("schLogoText")
public Map<String, Object> schLogoText(BoaUser authUserWithDept) {
ISchSettingsService.LayerValue hit =
schSettingsService.readLogoTextWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("logoText", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
/** 保存 LOGO 文字(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。 */
@PostMapping("saveSchLogoText")
public void saveSchLogoText(BoaUser authUserWithDept,
@RequestParam("logoText") String logoText) {
requireThemeAdmin(authUserWithDept);
String text = logoText == null ? "" : logoText.trim();
if (text.length() > 64) {
throw new DException("LOGO 文字不能超过 64 个字符");
}
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
schSettings.setCDate(new Date());
schSettings.setSchId(targetSchId);
schSettings.setCId(authUserWithDept.getId());
schSettings.setLogoText(text.isEmpty() ? null : text);
schSettingsService.save(schSettings);
}
// ===== 默认壁纸(平台/学校两级,壁纸三级默认轮 2026-10-09):与 LOGO 文字同模式 =====
/** 默认壁纸读(已登录可读):{wallpaperUrl: string|null, schId=实际命中层}。本校空 → 平台层 → null。 */
@GetMapping("schWallpaper")
public Map<String, Object> schWallpaper(BoaUser authUserWithDept) {
ISchSettingsService.LayerValue hit =
schSettingsService.readWallpaperWithFallback(schSettingsService.resolveThemeSchId(authUserWithDept, null));
Map<String, Object> out = new HashMap<>();
out.put("wallpaperUrl", hit.getValue());
out.put("schId", hit.getHitSchId());
return out;
}
/** 保存默认壁纸(仅学校管理员/超管):trim 后落本级(超管=平台层 0,校管=本校);空串存 null(=清空)。
* URL 为管理池返回的绝对地址原样存(与 user_settings.wallpaper_url 同口径),≤255。 */
@PostMapping("saveSchWallpaper")
public void saveSchWallpaper(BoaUser authUserWithDept,
@RequestParam("wallpaperUrl") String wallpaperUrl) {
requireThemeAdmin(authUserWithDept);
String url = wallpaperUrl == null ? "" : wallpaperUrl.trim();
if (url.length() > 255) {
throw new DException("壁纸地址不能超过 255 个字符");
}
Integer targetSchId = schSettingsService.resolveThemeSchId(authUserWithDept, null);
SchSettings schSettings = schSettingsService.findBySchIdRaw(targetSchId).orElseGet(SchSettings::new);
schSettings.setCDate(new Date());
schSettings.setSchId(targetSchId);
schSettings.setCId(authUserWithDept.getId());
schSettings.setWallpaperUrl(url.isEmpty() ? null : url);
schSettingsService.save(schSettings);
} }
@PostMapping("saveSchTheme") @PostMapping("saveSchTheme")
public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) { if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept); schId = schSettingsService.resolveSchId(authUserWithDept);
} }
SchSettings schSettings = new SchSettings(); SchSettings schSettings = new SchSettings();
Optional<SchSettings> model = schSettingsService.findBySchId(schId); Optional<SchSettings> model = schSettingsService.findBySchId(schId);
@@ -56,7 +233,7 @@ public class SchSettingsController {
@PostMapping("saveSchLogo") @PostMapping("saveSchLogo")
public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) { public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) { if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept); schId = schSettingsService.resolveSchId(authUserWithDept);
} }
SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept); SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept);
schSettings.setLogoUrl(logoUrl); schSettings.setLogoUrl(logoUrl);
@@ -68,7 +245,7 @@ public class SchSettingsController {
public Optional<SchSettings> getSchSettings(BoaUser authUserWithDept, public Optional<SchSettings> getSchSettings(BoaUser authUserWithDept,
@RequestParam(name = "schId", defaultValue = "0") Integer schId) { @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) { if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept); schId = schSettingsService.resolveSchId(authUserWithDept);
} }
return schSettingsService.findBySchId(schId); return schSettingsService.findBySchId(schId);
} }
@@ -5,7 +5,9 @@ import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config; import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.UsedApp; import com.yunhai.userhome.entity.UsedApp;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.model.SetType; import com.yunhai.userhome.model.SetType;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService; import com.yunhai.userhome.service.AppRemoteService;
import com.yunhai.userhome.service.UsedAppService; import com.yunhai.userhome.service.UsedAppService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -36,6 +38,9 @@ public class UsedAppController {
@Autowired @Autowired
private AppRemoteService appRemoteService; private AppRemoteService appRemoteService;
@Autowired
private AppIconKeyService appIconKeyService;
/** /**
* 取当前角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。 * 取当前角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
* *
@@ -112,7 +117,7 @@ public class UsedAppController {
usedAppService.saveUserId(usedApp); usedAppService.saveUserId(usedApp);
} }
/** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url),在全集中查找。 */ /** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url/iconKey/iconColor),在全集中查找。 */
@GetMapping("getAppByIds") @GetMapping("getAppByIds")
public List<Map<String, Object>> getAppByIds(BoaUser authUserWithDept, String appIds) { public List<Map<String, Object>> getAppByIds(BoaUser authUserWithDept, String appIds) {
List<Map<String, Object>> list = new ArrayList<>(); List<Map<String, Object>> list = new ArrayList<>();
@@ -132,6 +137,10 @@ public class UsedAppController {
m.put("name", app.getName()); m.put("name", app.getName());
m.put("icon", app.getIcon()); m.put("icon", app.getIcon());
m.put("url", app.getWebAddr()); m.put("url", app.getWebAddr());
// 矢量图标键(AppIcon 通道;未配置为 null,前端按空走原 img 回退)
AppIconEntryVo iconEntry = appIconKeyService.byId(app.getId());
m.put("iconKey", iconEntry == null ? null : iconEntry.getIconKey());
m.put("iconColor", iconEntry == null ? null : iconEntry.getIconColor());
list.add(m); list.add(m);
}); });
} catch (NumberFormatException ignore) { } catch (NumberFormatException ignore) {
@@ -1,15 +1,23 @@
package com.yunhai.userhome.controller.api; package com.yunhai.userhome.controller.api;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException; import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.base.OpenApiUserClient; import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.openapi.base.YschBaseOpenApi; import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.ErrorCode; import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaApp; import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaRoleInfo; import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config; import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService; import com.yunhai.userhome.service.AppRemoteService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@@ -18,7 +26,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
/** /**
* 用户首页接口(CAS 登录态,路径 {@code /{platform}/user/})。 * 用户首页接口(CAS 登录态,路径 {@code /{platform}/user/})。
@@ -28,12 +38,26 @@ import java.util.List;
@RequestMapping(value = Config.API_PATH + "/user/") @RequestMapping(value = Config.API_PATH + "/user/")
public class UserController { public class UserController {
private static final Logger log = LoggerFactory.getLogger(UserController.class);
/** 服务目录目标名:userbase(yh.open.services / 枚举兜底 = base-host + /userbase,与 MsgCenterProxyService 同口径) */
private static final String TARGET_USERBASE = "userbase";
@Autowired @Autowired
private AppRemoteService appRemoteService; private AppRemoteService appRemoteService;
@Autowired @Autowired
private OpenApiUserClient openApiUserClient; private OpenApiUserClient openApiUserClient;
@Autowired
private YschOpenCall openCall;
@Autowired
private AppIconKeyService appIconKeyService;
@Autowired
private ObjectMapper objectMapper;
/** 当前登录用户信息(CAS 注入)。 */ /** 当前登录用户信息(CAS 注入)。 */
@GetMapping("GetUserInfo") @GetMapping("GetUserInfo")
@@ -54,19 +78,56 @@ public class UserController {
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。 * 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。 * 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
* *
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与) * @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
* @param userId 指定用户(缺省用登录用户) * @param userId 指定用户(缺省用登录用户)
*/ */
@GetMapping("GetUserApp") @GetMapping("GetUserApp")
public List<BoaAppType> getUserApp(BoaUser authUserWithDept, Integer schId, public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
@RequestParam(name = "userId", required = false) Integer userId) { @RequestParam(name = "userId", required = false) Integer userId) {
return appRemoteService.getUserApps(authUserWithDept); return withIconKeys(appRemoteService.getUserApps(authUserWithDept, schId));
} }
/** 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。 */ /**
* 应用分组结果上合并注入矢量图标字段(iconKey/iconColor,AppIcon 资产库通道)。<br>
* BoaApp 属 BasePlatform SDK 本期不动:SDK 反序列化会丢弃 icon-map 的扩展字段,
* 故序列化前经 JsonNode 平铺注入;icon-map 不可达时按空映射跳过,出参结构与原值完全一致(零回归)。
*/
private JsonNode withIconKeys(List<BoaAppType> types) {
JsonNode root = objectMapper.valueToTree(types);
Map<Integer, AppIconEntryVo> iconMap = appIconKeyService.iconMap();
if (iconMap.isEmpty() || root == null || !root.isArray()) {
return root;
}
for (JsonNode type : root) {
JsonNode apps = type.get("apps");
if (apps == null || !apps.isArray()) {
continue;
}
for (JsonNode app : apps) {
JsonNode idNode = app.get("id");
if (idNode == null || !idNode.isNumber()) {
continue;
}
AppIconEntryVo entry = iconMap.get(idNode.asInt());
if (entry != null) {
((com.fasterxml.jackson.databind.node.ObjectNode) app)
.put("iconKey", entry.getIconKey())
.put("iconColor", entry.getIconColor());
}
}
}
return root;
}
/**
* 按应用ID取单个应用(在已拉取全集中查找,免额外跨仓请求)。<br>
* schId 透传(可选):与 GetUserApp 同口径按学校收窄全集,避免「列表已收窄、单个查询仍全集」的不一致;
* 为空则由 UserBase 按当前身份/主归属学校兜底。
*/
@GetMapping("GetAppInfoById") @GetMapping("GetAppInfoById")
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) { public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
return appRemoteService.getAppById(authUserWithDept, id).orElse(null); @RequestParam(name = "id", required = false) Integer id) {
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
} }
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */ /** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
@@ -92,4 +153,43 @@ public class UserController {
} }
openApiUserClient.switchIdentity(authUserWithDept.getId(), roleInfo.getRoleId(), roleInfo.getSchId()); openApiUserClient.switchIdentity(authUserWithDept.getId(), roleInfo.getRoleId(), roleInfo.getSchId());
} }
/**
* 本人联系方式修改(手机号/邮箱):body {phone, email},userId 只取 CAS 会话(无视请求体,防替改他人),
* 经 {@link YschOpenCall} 泛化签名直调(Tier 0)转发 UserBase {@code POST /oapi/user/self/contact}
* (与 MsgCenterProxyService 直调 userbase 同范式:服务目录按名取址 + 信封异常透传,零新增配置项)。
* <ul>
* <li>phone/email 至少一项非空(前端与 UserBase 侧同口径校验,此处仅挡空请求);</li>
* <li>UserBase 业务失败(格式/查重等)以 DException(code,msg) 冒泡,由 GlobalExceptionHandler
* 原样包成信封返回(msg 即「手机号已被其他用户使用」等明确错误);</li>
* <li>UserBase 不可达(ioError/验签失败等 OpenApiException)收口为友好提示,不抛 500 裸栈;</li>
* <li>成功返回 UserBase 更新后的最新 {@link BoaUser}(含新 phone/email/groups),前端以此
* 覆盖 store.userInfo,规避 CAS principal 缓存导致的旧值回显。</li>
* </ul>
*/
@PostMapping("UpdateContact")
public BoaUser updateContact(BoaUser authUserWithDept, @RequestBody Map<String, String> body) {
String phone = body == null ? null : body.get("phone");
String email = body == null ? null : body.get("email");
boolean phoneBlank = phone == null || phone.trim().isEmpty();
boolean emailBlank = email == null || email.trim().isEmpty();
if (phoneBlank && emailBlank) {
throw new BizException(ErrorCode.PARAM_ERROR, "手机号与邮箱至少填写一项");
}
Map<String, String> payload = new LinkedHashMap<>();
payload.put("userId", String.valueOf(authUserWithDept.getId()));
if (!phoneBlank) {
payload.put("phone", phone.trim());
}
if (!emailBlank) {
payload.put("email", email.trim());
}
try {
return openCall.post(TARGET_USERBASE, "/oapi/user/self/contact", null, payload, BoaUser.class);
} catch (OpenApiException e) {
log.warn("[user] UpdateContact 直调 UserBase 失败 userId={}:{}", authUserWithDept.getId(), e.toString());
throw new BizException(ErrorCode.CUSTOM, "用户中心暂不可达,请稍后重试");
}
// DException(UserBase 业务错误,code!=0)刻意冒泡:GlobalExceptionHandler 按原 code/msg 包信封
}
} }
@@ -14,9 +14,14 @@ import com.yunhai.userhome.entity.*;
import com.yunhai.userhome.enums.DesktopAppType; import com.yunhai.userhome.enums.DesktopAppType;
import com.yunhai.userhome.enums.LightAppGroup; import com.yunhai.userhome.enums.LightAppGroup;
import com.yunhai.userhome.enums.LinkOpenType; import com.yunhai.userhome.enums.LinkOpenType;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService; 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.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;
@@ -31,11 +36,6 @@ import java.util.stream.Collectors;
/** /**
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。 * 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
*
* <p>待确认接口(已写入待确认清单):
* <ul>
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
* </ul>
*/ */
@RestController @RestController
@RequestMapping(Config.API_PATH) @RequestMapping(Config.API_PATH)
@@ -50,15 +50,27 @@ public class UserSettingsController {
@Autowired @Autowired
private IUserSettingsService userSettingsService; private IUserSettingsService userSettingsService;
@Autowired
private ISchSettingsService schSettingsService;
@Autowired @Autowired
private IUserCurrentRoleService userCurrentRoleService; private IUserCurrentRoleService userCurrentRoleService;
@Autowired @Autowired
private UsedAppService usedAppService; private UsedAppService usedAppService;
@Autowired
private WidgetLinkResolver widgetLinkResolver;
@Autowired @Autowired
private OpenApiUserClient openApiUserClient; private OpenApiUserClient openApiUserClient;
@Autowired
private AppIconKeyService appIconKeyService;
@Autowired
private IWidgetCatalogService widgetCatalogService;
// ==================== 壁纸 ==================== // ==================== 壁纸 ====================
/** /**
@@ -277,21 +289,37 @@ public class UserSettingsController {
public UserSettings getUserSettings(BoaUser authUserWithDept) { public UserSettings getUserSettings(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId()); Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null; Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
UserSettings model = new UserSettings(); UserSettings model = new UserSettings();
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId()); Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认 // 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId); List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
List<BoaApp> appPageList = Collections.emptyList();
if (optional.isPresent()) model = optional.get(); if (optional.isPresent()) model = optional.get();
if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) { // 壁纸池一次取全:悬空自愈判定与兜底选择共用
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId)); List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId());
// 悬空引用自愈:自选壁纸已被删除(删除清理修复前的存量脏数据)时视为未自选,避免返回死 URL 白板
String chosenUrl = normalizeWallpaperUrl(model.getWallpaperUrl());
if (model.getWallpaperUrl() != null && !model.getWallpaperUrl().isEmpty()
&& wallList.stream().noneMatch(w -> chosenUrl != null && chosenUrl.equals(w.getWallpaperUrl()))) {
model.setWallpaperUrl(null);
} }
List<Map> mapList = appPageList.stream().map(boaApp -> { if (model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) {
model.setWallpaperUrl(resolveFallbackWallpaper(authUserWithDept, schId, wallList));
}
// id/appId 同值:对齐既有 widgetAppIds 的存取与面板排序链路;不再有 url
List<Map> mapList = catalogList.stream().map(catalog -> {
Map map = new HashMap<>(); Map map = new HashMap<>();
map.put("id", boaApp.getId()); map.put("id", catalog.getId());
map.put("appId", boaApp.getId()); map.put("appId", catalog.getId());
map.put("name", boaApp.getName()); map.put("name", catalog.getName());
map.put("url", boaApp.getWebAddr()); 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; return map;
}).collect(Collectors.toList()); }).collect(Collectors.toList());
@@ -300,25 +328,34 @@ public class UserSettingsController {
} }
/** /**
* 兜底壁纸(用户未设置时):本地学校壁纸第一张 → 超管全局壁纸第一张。 * 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定)
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。</p> * → 平台默认(schId=0 行)→ 本地学校壁纸池第一张 → 平台池第一张(历史兜底,两级皆未配置时保持原体验)。
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。
* 管理员选定默认返回前校验其仍在壁纸池中——被删池视为未配置继续下落,避免兜底本身返回死 URL(2026-10-09 白板修复)。</p>
*/ */
private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId) { private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId, List<Wallpaper> wallList) {
List<Wallpaper> wallList = userSettingsService.findWallList(authUserWithDept.getId()); Set<String> poolUrls = new HashSet<>();
wallList.forEach(w -> poolUrls.add(w.getWallpaperUrl()));
// 1/2. 管理员选定的默认壁纸(本校 → 平台 级联,读侧 LayerValue)
ISchSettingsService.LayerValue wallHit = schSettingsService.readWallpaperWithFallback(schId);
if (wallHit.getValue() != null && poolUrls.contains(normalizeWallpaperUrl(wallHit.getValue()))) {
return wallHit.getValue();
}
// 3/4. 历史兜底:壁纸池第一张(校 type=1 → 平台 type=0,最早上传者优先)
if (schId != null) { if (schId != null) {
// 1. 本地学校壁纸第一张(type=1,取最新) // 3. 本地学校壁纸第一张(type=1)
Optional<Wallpaper> schWall = wallList.stream() Optional<Wallpaper> schWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 1 .filter(w -> w.getType() != null && w.getType() == 1
&& w.getSchId() != null && w.getSchId().equals(schId)) && w.getSchId() != null && w.getSchId().equals(schId))
.max(Comparator.comparing(Wallpaper::getId)); .min(Comparator.comparing(Wallpaper::getId));
if (schWall.isPresent()) { if (schWall.isPresent()) {
return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", ""); return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", "");
} }
} }
// 2. 超管全局壁纸第一张(type=0,取最新) // 4. 超管全局壁纸第一张(type=0)
Optional<Wallpaper> sysWall = wallList.stream() Optional<Wallpaper> sysWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 0) .filter(w -> w.getType() != null && w.getType() == 0)
.max(Comparator.comparing(Wallpaper::getId)); .min(Comparator.comparing(Wallpaper::getId));
if (sysWall.isPresent()) { if (sysWall.isPresent()) {
return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", ""); return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", "");
} }
@@ -376,6 +413,10 @@ public class UserSettingsController {
map.put("type", DesktopAppType.Bsch.getKey()); map.put("type", DesktopAppType.Bsch.getKey());
map.put("linkType", LinkOpenType.Open.getKey()); map.put("linkType", LinkOpenType.Open.getKey());
map.put("layout", null); map.put("layout", null);
// 矢量图标键(AppIcon 通道;未配置为 null,前端按空走原 img 回退)
AppIconEntryVo iconEntry = appIconKeyService.byId(optionalBoaApp.get().getId());
map.put("iconKey", iconEntry == null ? null : iconEntry.getIconKey());
map.put("iconColor", iconEntry == null ? null : iconEntry.getIconColor());
} else if (desktopApp.getAppType() == DesktopAppType.Light) { } else if (desktopApp.getAppType() == DesktopAppType.Light) {
Optional<LightApp> optionalLightApp = lightAllList.stream() Optional<LightApp> optionalLightApp = lightAllList.stream()
.filter(app -> desktopApp.getAppId().equals(app.getId())).findFirst(); .filter(app -> desktopApp.getAppId().equals(app.getId())).findFirst();
@@ -0,0 +1,179 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.WidgetDataService;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.DigestUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletResponse;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 小组件控制器:候选池按需取内容 + 统一取数(登录侧),以及管理端候选池读写(管理员)。
*
* <p>写权限按「目录权 / 校级权」拆开:全局目录(新增 / 改内容 / 删除 / 三态可见范围 / 角色命名)仅超管;
* 校级覆盖(启用 / 可见角色 / 排序 / 别名)超管或校管,且校管只能写本校。</p>
*
* <p>真实路径按 {@code /{platform}} 展开为 {@code /api/getWidgetData} 等(见 Config.API_PATH)。</p>
*/
@RestController
@RequestMapping(Config.API_PATH)
public class WidgetController {
private static final int COMP_TYPE_FRAGMENT = 2;
@Autowired
private IWidgetCatalogService widgetCatalogService;
@Autowired
private WidgetDataService widgetDataService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
/** 统一取数入口:params 为 urlencoded 查询串;未配置的数据键回空数据,不抛异常 */
@GetMapping(value = "getWidgetData", produces = "application/json")
public Object getWidgetData(BoaUser authUserWithDept,
@RequestParam(value = "code", required = false) String code,
@RequestParam(value = "params", required = false) String params) {
Object data = widgetDataService.getData(code, params, resolveCaller(authUserWithDept));
if (data != null) {
return data;
}
Map<String, Object> empty = new HashMap<>();
empty.put("msg", "该小工具暂无可用数据源:" + code);
return empty;
}
/** 仅 comp_type=2:按需取片段内容(不塞进 getUserSettings,避免每次进桌面下发全部 HTML) */
@GetMapping(value = "getWidgetContent", produces = "application/json")
public Map<String, Object> getWidgetContent(BoaUser authUserWithDept, HttpServletResponse response,
@RequestParam(value = "code", required = false) String code) {
Map<String, Object> result = new HashMap<>();
result.put("content", "");
Optional<WidgetCatalog> optional = widgetCatalogService.findByCode(code);
if (!optional.isPresent()) {
result.put("msg", "小工具不存在:" + code);
return result;
}
WidgetCatalog catalog = optional.get();
if (!widgetCatalogService.visibleToSchool(catalog, currentSchId(authUserWithDept))) {
result.put("msg", "该小工具未对本校下发");
return result;
}
if (catalog.getCompType() == null || catalog.getCompType() != COMP_TYPE_FRAGMENT) {
result.put("msg", "内置组件的内容在 front 工程里,无按需取内容这条路:" + code);
return result;
}
String content = catalog.getContent() == null ? "" : catalog.getContent();
result.put("code", catalog.getCode());
result.put("name", catalog.getName());
result.put("compType", catalog.getCompType());
result.put("dataCode", catalog.getDataCode());
result.put("content", content);
// 内容 hash 派生 ETag:内容或更新时间变更即换标签,便于前端条件请求复用
response.setHeader("ETag", "\"" + contentHash(content, catalog.getUpdateTime()) + "\"");
return result;
}
/** 管理端列表(超管 / 校管):按视角返回——超管=全局原值,校管=本校生效值 + 原名 + 覆盖标记 */
@GetMapping(value = "GetWidgetCatalogList", produces = "application/json")
public List<WidgetCatalog> getWidgetCatalogList(BoaUser authUserWithDept) {
checkSchAdmin(authUserWithDept);
return widgetCatalogService.findEffectiveForAdmin(currentSchId(authUserWithDept), isSuperAdmin(authUserWithDept));
}
/** 管理端保存全局目录:content 走 JSON body(同 SaveTempFile 口径,规避 URL 长度限制) */
@PostMapping(value = "SaveWidgetCatalog", produces = "application/json")
public void saveWidgetCatalog(BoaUser authUserWithDept, @RequestBody WidgetCatalog widgetCatalog) {
checkSuperAdmin(authUserWithDept);
widgetCatalogService.save(widgetCatalog);
}
/** 删除全局目录行,连带删可见范围点名表与校级覆盖行 */
@PostMapping(value = "DeleteWidgetCatalog", produces = "application/json")
public void deleteWidgetCatalog(BoaUser authUserWithDept, @RequestParam(value = "id") Integer id) {
checkSuperAdmin(authUserWithDept);
widgetCatalogService.deleteById(id);
}
/** 读取本校覆盖行(编辑回显):只认当前登录校 */
@GetMapping(value = "GetWidgetSchoolSetting", produces = "application/json")
public WidgetSchoolSetting getWidgetSchoolSetting(BoaUser authUserWithDept,
@RequestParam(value = "widgetId") Integer widgetId) {
checkSchAdmin(authUserWithDept);
return widgetCatalogService.findSchoolSetting(widgetId, currentSchId(authUserWithDept)).orElse(null);
}
/** 保存本校覆盖:schId 强制取当前登录校,不接受前端传入 */
@PostMapping(value = "SaveWidgetSchoolSetting", produces = "application/json")
public void saveWidgetSchoolSetting(BoaUser authUserWithDept, @RequestBody WidgetSchoolSetting setting) {
checkSchAdmin(authUserWithDept);
widgetCatalogService.saveSchoolSetting(setting, currentSchId(authUserWithDept));
}
private Integer currentSchId(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
return currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
}
/**
* 取数身份上下文(userid / roleId / schId 全部会话推导,前端不可传):
* userid = 平台用户ID(BoaUser.id);roleId/schId = 当前激活身份(UserBase /oapi/identity/current)。
*/
private WidgetDataService.Caller resolveCaller(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
return new WidgetDataService.Caller(authUserWithDept.getId(), roleId, schId);
}
private boolean isSuperAdmin(BoaUser authUserWithDept) {
return authUserWithDept.getType() == 1;
}
/**
* 目录权校验:仅超管(type==1)可写全局目录 / 删除 / 三态可见范围 / 角色命名 / 登记内置。
* 注:平台 yh-api-std 的异常处理器无 @ResponseStatus,拒绝表现为 HTTP 200 + 信封 code=3(非 500)。
*/
private void checkSuperAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() != 1) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
/** 校级权校验:超管或校管理员(roleId∈{2,5,7}),其余拒绝 */
private void checkSchAdmin(BoaUser authUserWithDept) {
if (authUserWithDept.getType() == 1) {
return;
}
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
if (!currentRoleOpt.isPresent() || !OtherUtil.isAdminById(currentRoleOpt.get().getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
}
private String contentHash(String content, java.util.Date updateTime) {
long stamp = updateTime == null ? 0L : updateTime.getTime();
return DigestUtils.md5DigestAsHex((stamp + "\n" + content).getBytes(StandardCharsets.UTF_8));
}
}
@@ -0,0 +1,35 @@
package com.yunhai.userhome.controller.oapi;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.service.IAppearanceService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 外观偏好开放接口(oapi 代理轮 2026-10-08):子应用后端经 open-gateway 签名直调(target=userhome),
* 替代浏览器直连 /api/appearance + CAS iframe 引导。入站验签由 {@code OpenApiAuthEnforceConfiguration}
* 强制(/oapi/* 无签名 401);appKey 准入归网关台账/限流管,本应用不维护 per-app 白名单。
*
* <p>出参 = 当前用户【生效值】{@code {theme, avatar, logoUrl}}(个人 → 学校 → 平台级联合成,
* 层级知识留在 UserHome 内部不外露;null=未配置,消费端 init 侧清对应本地键回落默认观感;
* logoText 已于 2026-10-09 定稿摘除——子应用不再消费 LOGO 文字,门户走 /api/schLogoText 自有链)。
* 返回裸 payload(平台 oapi 无信封包装),消费端整体透传给浏览器。</p>
*/
@RestController
@RequestMapping(value = Config.OPEN_API_PATH)
public class OapiAppearanceController {
@Autowired
private IAppearanceService appearanceService;
/** 当前用户生效外观。userId 由调用方(子应用后端)从其登录态取,服务间签名保证调用方可信。 */
@GetMapping("appearance")
public Map<String, Object> appearance(@RequestParam("userId") Integer userId) {
return appearanceService.getEffectiveAppearance(userId);
}
}
@@ -1,60 +0,0 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.validation.constraints.NotNull;
import java.util.Date;
/**
* 桌面应用类别(自 systemsetting.app_category 1:1 迁移)。
*
* <p>roleType:1 校管理员 | 2 教师 | 3 学生(见 {@code enums.RoleType});
* resDate 为资源更新时间戳,前端上传模板后经 UpdateResDate 刷新缓存。</p>
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "app_category")
public class AppCategory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = Update.class)
private Integer id;
private String name;
@Column(name = "api_url", length = 500)
private String apiUrl;
@Column(name = "data_key")
private String dataKey;
/** 接口参数 JSON 数组字符串([{k,v,type}]) */
@Column(name = "params_key", columnDefinition = "TEXT")
private String paramsKey;
@Column(name = "role_type")
private Integer roleType;
@Column(name = "c_id")
private Integer cId;
@Column(name = "c_date")
private Date cDate = new Date();
/** 资源更新时间戳 */
@Column(name = "res_date")
private Date resDate = new Date();
public @interface Update {}
}
@@ -30,9 +30,22 @@ public class SchSettings {
@Column(name = "theme_name") @Column(name = "theme_name")
private String themeName; private String themeName;
/** 学校默认头像样式(默认头像轮 2026-09-30):text|silhouette;null=未配置(读侧可空不兜底) */
@Column(name = "avatar_style")
private String avatarStyle;
@Column(name = "logo_url") @Column(name = "logo_url")
private String logoUrl; private String logoUrl;
/** LOGO 配套平台/学校名称文字(LOGO 图在 logo 表池,文字是单值配置随 settings;NULL=未配置回落上级) */
@Column(name = "logo_text")
private String logoText;
/** 默认壁纸(壁纸三级默认轮 2026-10-09;绝对 URL,管理员从本级壁纸池选定;NULL=未配置回落上级。
* 个人未自选时生效链:本校默认 → 平台默认(schId=0 行)→ 壁纸池最新一张(历史兜底) */
@Column(name = "wallpaper_url")
private String wallpaperUrl;
@Column(name = "home_img_url") @Column(name = "home_img_url")
private String homeImgUrl; private String homeImgUrl;
@@ -0,0 +1,50 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.util.Date;
/**
* 用户外观偏好(user_appearance 表,外观偏好服务端化 2026-10-08)。
* <p>跨浏览器一致的真源,全局 per-user 一行(user_id 唯一)——与 localStorage 键同维度,
* 刻意不挂 user_settings 的 sch/role 维度。</p>
* <p>theme_state 存紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01}(如 {@code ocean|1|0|1});
* avatar_style 取值 {@code text|silhouette};两列 NULL = 跟随学校默认。</p>
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "user_appearance", uniqueConstraints = {@UniqueConstraint(columnNames = "user_id", name = "uk_user")})
public class UserAppearance {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "user_id", nullable = false)
private Integer userId;
/** 用户自选主题档紧凑编码;NULL=跟随学校默认 */
@Column(name = "theme_state")
private String themeState;
/** 用户自选头像样式 text|silhouette;NULL=跟随学校默认 */
@Column(name = "avatar_style")
private String avatarStyle;
@Column(name = "c_date")
private Date cDate;
@Column(name = "u_date")
private Date uDate;
}
@@ -0,0 +1,103 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.Transient;
import javax.persistence.UniqueConstraint;
import java.util.Date;
import java.util.List;
/**
* 小组件候选池(widget_catalog 表):内容自持,全表无 html 地址概念。
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_catalog", uniqueConstraints = @UniqueConstraint(columnNames = "code"))
public class WidgetCatalog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** 内置组件 = 前端 widgetRegistry 的键;自定义片段 = 自定义标识 */
@Column(name = "code", nullable = false, length = 32)
private String code;
@Column(name = "name", nullable = false, length = 64)
private String name;
/** 1 = front 工程内置组件(内容在代码库,本行不存 content);2 = 自定义 HTML 片段(内容在 content) */
@Column(name = "comp_type", nullable = false)
private Integer compType = 1;
/** 仅 comp_type=2:片段 HTML 源码 */
@Column(name = "content", columnDefinition = "mediumtext")
private String content;
/** 片段取数用的数据键,须在 WidgetDataService 白名单内 */
@Column(name = "data_code", length = 32)
private String dataCode;
/** 下发范围:旧语义(NULL = 全校可见)已被 scopeType 取代,字段保留仅为兼容存量数据,读侧不再使用 */
@Column(name = "sch_id")
private Integer schId;
/** 下发范围三态:0 = 全部学校 1 = 白名单(仅点名的学校可见) 2 = 黑名单(点名的学校不可见) */
@Column(name = "scope_type", nullable = false)
private Integer scopeType = 0;
/** 可见角色:逗号分隔 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生);NULL 或空 = 全角色 */
@Column(name = "role_types", length = 16)
private String roleTypes;
/** 角色专属命名(JSON 文本,如 {"2":"全校课表"},键 = RoleTypeEnum);读侧 Jackson 解析,失败按无角色命名降级 */
@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;
/** 1 = 进候选池,0 = 下线 */
@Column(name = "enabled", nullable = false)
private Integer enabled = 1;
@Column(name = "update_time")
private Date updateTime;
/** 管理端视图:scopeType=1/2 时点名的学校 id 列表(非持久化,随 SaveWidgetCatalog 一并提交) */
@Transient
private List<Integer> scopeSchIds;
/** 管理端视图(校管视角):本校别名(widget_school_setting.alias,非持久化) */
@Transient
private String alias;
/** 管理端视图(校管视角):超管下发的全局名称(非持久化,用于弹窗回显原名) */
@Transient
private String originalName;
/** 管理端视图(校管视角):本校是否已有覆盖行(非持久化) */
@Transient
private Boolean covered;
}
@@ -0,0 +1,43 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 小组件可见范围点名表(widget_catalog_school):只存被点名的学校行。
*
* <p>配合 {@link WidgetCatalog#getScopeType()}:1(白名单)= 表中有记录的学校可见;
* 2(黑名单)= 表中有记录的学校不可见。</p>
*
* @author chenx
* @date 2026-10-11
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_catalog_school",
uniqueConstraints = @UniqueConstraint(name = "uk_widget_school", columnNames = {"widget_id", "sch_id"}))
public class WidgetCatalogSchool {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
/** widget_catalog.id */
@Column(name = "widget_id", nullable = false)
private Integer widgetId;
/** 被点名的学校 id */
@Column(name = "sch_id", nullable = false)
private Integer schId;
}
@@ -0,0 +1,69 @@
package com.yunhai.userhome.entity;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
import java.util.Date;
/**
* 小组件校级覆盖层(widget_school_setting):一校一行。
*
* <p>字段为 NULL 表示沿用全局值,生效值 = COALESCE(本行, widget_catalog 全局值);
* 「一键清除覆盖」= 删除本行。</p>
*
* @author chenx
* @date 2026-10-11
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "widget_school_setting",
uniqueConstraints = @UniqueConstraint(name = "uk_widget_sch", columnNames = {"widget_id", "sch_id"}))
public class WidgetSchoolSetting {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "widget_id", nullable = false)
private Integer widgetId;
@Column(name = "sch_id", nullable = false)
private Integer schId;
/** NULL = 沿用全局 enabled */
@Column(name = "enabled")
private Integer enabled;
/** NULL = 沿用全局 role_types */
@Column(name = "role_types", length = 16)
private String roleTypes;
/** NULL = 沿用全局 sort */
@Column(name = "sort_no")
private Integer sortNo;
/** 本校别名,NULL = 沿用全局 name */
@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;
}
@@ -0,0 +1,42 @@
package com.yunhai.userhome.model;
/**
* UserBase 应用矢量图标映射条目({@code /oapi/app/icon-map} 出参镜像)。<br>
* BoaApp 属 BasePlatform SDK 本期不动,PC 通道的 iconKey/iconColor 经本 VO 独立补拉后
* 在 GetUserApp / 常用区出参上合并注入(加法式改造,不动既有 oapi 契约)。
*/
public class AppIconEntryVo {
/** 应用主键 ID(与 BoaApp.id 同源,按此合并) */
private Integer id;
/** 矢量图标键(AppIcon 资产库图标名) */
private String iconKey;
/** 矢量图标着色(CSS 色值);null = 端侧默认 */
private String iconColor;
public Integer getId() {
return id;
}
public void setId(Integer id) {
this.id = id;
}
public String getIconKey() {
return iconKey;
}
public void setIconKey(String iconKey) {
this.iconKey = iconKey;
}
public String getIconColor() {
return iconColor;
}
public void setIconColor(String iconColor) {
this.iconColor = iconColor;
}
}
@@ -0,0 +1,19 @@
package com.yunhai.userhome.model;
import lombok.Data;
/**
* 卡片直批入参(B3 §2)。只含消息定位与动作——operatorId/schId/operatorName
* 一律服务端从当前会话取,绝不从前端接收(防越权代批)。
*/
@Data
public class MsgHandleRequest {
private Long msgId;
/** approve | reject */
private String action;
/** 办理意见:拒绝必填,同意可选 */
private String opinion;
}
@@ -1,12 +0,0 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.AppCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface AppCategoryRepository extends JpaRepository<AppCategory, Integer>, JpaSpecificationExecutor<AppCategory> {
List<AppCategory> findAllByRoleType(Integer roleType);
}
@@ -0,0 +1,13 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.UserAppearance;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
@Repository
public interface UserAppearanceRepository extends JpaRepository<UserAppearance, Integer> {
Optional<UserAppearance> findByUserId(Integer userId);
}
@@ -8,6 +8,7 @@ import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param; import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.Optional; import java.util.Optional;
public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> { public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> {
@@ -21,8 +22,10 @@ public interface UserSettingsRepository extends JpaRepository<UserSettings, Inte
/** /**
* 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。 * 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。
* user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),wall 表存原始形态,
* 两种形态都需传入匹配,漏形态会留下悬空引用(读侧拿到已删壁纸的死 URL 白板)。
*/ */
@Modifying @Modifying
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl = :url") @Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl in :urls")
int clearWallpaperUrl(@Param("url") String url); int clearWallpaperUrl(@Param("urls") Collection<String> urls);
} }
@@ -0,0 +1,34 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetCatalog;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface WidgetCatalogRepository extends JpaRepository<WidgetCatalog, Integer>,
JpaSpecificationExecutor<WidgetCatalog> {
Optional<WidgetCatalog> findByCode(String code);
List<WidgetCatalog> findByEnabledOrderBySortAscIdAsc(Integer enabled);
List<WidgetCatalog> findAllByOrderBySortAscIdAsc();
/**
* 某校「可见范围命中」的目录行(不含启用状态、角色过滤与校级覆盖,由服务层合成)。
* 一处 SQL 决定 scope_type 语义:0 全放;1 仅点名表命中;2 仅点名表未命中。
*/
@Query("select w from WidgetCatalog w where coalesce(w.scopeType, 0) = 0 "
+ "or (coalesce(w.scopeType, 0) = 1 and exists "
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
+ "or (coalesce(w.scopeType, 0) = 2 and not exists "
+ "(select 1 from WidgetCatalogSchool s where s.widgetId = w.id and s.schId = :schId)) "
+ "order by w.sort asc, w.id asc")
List<WidgetCatalog> findVisibleForSchool(@Param("schId") Integer schId);
}
@@ -0,0 +1,27 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetCatalogSchool;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.List;
/**
* 小组件可见范围点名表访问。
*
* @author chenx
* @date 2026-10-11
*/
@Repository
public interface WidgetCatalogSchoolRepository extends JpaRepository<WidgetCatalogSchool, Integer> {
/** 命中判定:某组件是否点名了某校(scope_type 白/黑名单共用) */
boolean existsByWidgetIdAndSchId(Integer widgetId, Integer schId);
List<WidgetCatalogSchool> findByWidgetId(Integer widgetId);
List<WidgetCatalogSchool> findByWidgetIdIn(Collection<Integer> widgetIds);
void deleteByWidgetId(Integer widgetId);
}
@@ -0,0 +1,26 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
/**
* 小组件校级覆盖层访问。
*
* @author chenx
* @date 2026-10-11
*/
@Repository
public interface WidgetSchoolSettingRepository extends JpaRepository<WidgetSchoolSetting, Integer> {
Optional<WidgetSchoolSetting> findByWidgetIdAndSchId(Integer widgetId, Integer schId);
List<WidgetSchoolSetting> findBySchId(Integer schId);
List<WidgetSchoolSetting> findByWidgetId(Integer widgetId);
void deleteByWidgetId(Integer widgetId);
}
@@ -0,0 +1,79 @@
package com.yunhai.userhome.service;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.userhome.model.AppIconEntryVo;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
/**
* 应用矢量图标映射补拉(AppIcon 资产库 P2/P3 数据通道)。<br>
* UserBase 的 {@code GET /oapi/app/icon-map} 返回全量已配置行 [{id, iconKey, iconColor}],
* 本服务拉取后建 id 索引供各出参组装点合并注入(BoaApp 属 SDK 本期不动,矢量字段走本通道)。<br>
* <ul>
* <li>60 秒本地缓存:多个出参点(GetUserApp/常用区/搜索)一次渲染只打一次跨仓调用;</li>
* <li>失败降级:返回上次成功值(无则空映射),绝不阻塞主应用列表——空映射下端侧
* 走原 关键词→URL→首字 回退链,表现与改造前一致(零回归要求);</li>
* <li>失败后 10 秒内不重试(简单防雪崩)。</li>
* </ul>
*/
@Service
public class AppIconKeyService {
private static final Logger log = LoggerFactory.getLogger(AppIconKeyService.class);
/** 服务目录目标名:userbase(与 UserController.TARGET_USERBASE 同口径) */
private static final String TARGET_USERBASE = "userbase";
private static final String PATH_ICON_MAP = "/oapi/app/icon-map";
/** 成功结果缓存时长 */
private static final long TTL_MS = 60_000L;
/** 拉取失败后的静默期 */
private static final long FAIL_BACKOFF_MS = 10_000L;
@Autowired
private YschOpenCall openCall;
private volatile Map<Integer, AppIconEntryVo> cached = Collections.emptyMap();
private volatile long fetchedAt = Long.MIN_VALUE;
/**
* id → 矢量图标条目 映射(含缓存与降级)。空映射 = 无任何应用配置矢量键或通道不可达。
*/
public Map<Integer, AppIconEntryVo> iconMap() {
long now = System.currentTimeMillis();
if (now - fetchedAt < TTL_MS) {
return cached;
}
try {
List<AppIconEntryVo> list = openCall.getList(TARGET_USERBASE, PATH_ICON_MAP, null,
AppIconEntryVo.class);
Map<Integer, AppIconEntryVo> map = (list == null) ? Collections.emptyMap()
: list.stream()
.filter(v -> v != null && v.getId() != null && v.getIconKey() != null)
.collect(Collectors.toMap(AppIconEntryVo::getId, Function.identity(), (a, b) -> a));
cached = Collections.unmodifiableMap(map);
fetchedAt = now;
return cached;
} catch (Exception e) {
// 降级:沿用上次值;10 秒内不再重试。应用列表主链路绝不因此报错
fetchedAt = now - TTL_MS + FAIL_BACKOFF_MS;
log.warn("[appicon] icon-map 拉取失败,按空映射降级:{}", e.toString());
return cached;
}
}
/** 按 id 取单条;无配置返回 null。 */
public AppIconEntryVo byId(Integer appId) {
return (appId == null) ? null : iconMap().get(appId);
}
}
@@ -16,9 +16,31 @@ import java.util.Optional;
*/ */
public interface AppRemoteService { public interface AppRemoteService {
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */ /**
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
*/
List<BoaAppType> getUserApps(BoaUser user); List<BoaAppType> getUserApps(BoaUser user);
/**
* 按学校上下文取用户可访问应用全集(2026-10-12 学校单点开通硬闸门贯通)。<br>
* {@code schoolId} 非空 → UserBase 按该校自身单点开通集合收窄;为空 → 服务端按身份/主归属兜底。
*
* @param user 登录用户
* @param schoolId 学校上下文(可为 null)
*/
List<BoaAppType> getUserApps(BoaUser user, Integer schoolId);
/** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */ /** 按应用ID取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
Optional<BoaApp> getAppById(BoaUser user, Integer appId); Optional<BoaApp> getAppById(BoaUser user, Integer appId);
/**
* 按应用ID取单个应用,并按学校上下文收窄全集(与 {@link #getUserApps(BoaUser, Integer)} 同口径,
* 避免「列表已按校收窄、单个查询仍全集」的不一致)。
*
* @param user 登录用户
* @param appId 应用ID
* @param schoolId 学校上下文(可为 null)
*/
Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId);
} }
@@ -1,22 +0,0 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.AppCategory;
import org.springframework.data.domain.Page;
import java.util.List;
import java.util.Optional;
public interface IAppCategoryService {
void save(AppCategory appCategory);
void deleteById(Integer id);
Optional<AppCategory> findById(Integer id);
List<AppCategory> findByRoleType(Integer roleType);
Page<AppCategory> findPageList(Integer page, Integer count, Integer roleType, String name);
void updateResDate(Integer id);
}
@@ -0,0 +1,31 @@
package com.yunhai.userhome.service;
import com.yunhai.platform.utils.model.BoaUser;
import java.util.Map;
/**
* 用户外观偏好聚合服务(外观偏好服务端化 2026-10-08):
* 主题档/头像样式 per-user 真源(user_appearance)+ 学校默认(sch_settings)+ 学校 Logo(logo 表)。
*/
public interface IAppearanceService {
/**
* 聚合出参:{myTheme, schTheme, avatarMy, avatarSch, logoUrl},查不到/脏值/依赖故障的字段一律 null,
* 不抛异常(子应用异步补齐段按"缺字段=清键回落默认"消费)。
*/
Map<String, Object> getAppearance(BoaUser user);
/**
* 当前用户生效外观(oapi 代理轮 2026-10-08):{theme, avatar, logoUrl}——主题/头像为合成后的
* 【生效值】(个人 → 学校 → 平台级联,层级知识不外露);logoUrl 本校→平台择优。
* 查不到/脏值/依赖故障的字段 null(init 侧清对应键回落默认观感)。
*/
Map<String, Object> getEffectiveAppearance(Integer userId);
/** 保存用户自选主题档(紧凑编码 theme|sideDeep|gradOn|sideFollow);themeState=null 表示跟随学校默认 */
void saveMyTheme(Integer userId, String themeState);
/** 保存用户自选头像样式(text|silhouette);avatarStyle=null 表示跟随学校默认 */
void saveMyAvatarStyle(Integer userId, String avatarStyle);
}
@@ -1,5 +1,6 @@
package com.yunhai.userhome.service; package com.yunhai.userhome.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.SchSettings; import com.yunhai.userhome.entity.SchSettings;
import java.util.List; import java.util.List;
@@ -12,5 +13,55 @@ public interface ISchSettingsService {
Optional<SchSettings> findBySchId(Integer schId); Optional<SchSettings> findBySchId(Integer schId);
/** 原样读(无兜底):学校默认主题档等"可空语义"场景专用——查无返回 empty,不合成默认对象 */
Optional<SchSettings> findBySchIdRaw(Integer schId);
List<SchSettings> findBySchIds(List<Integer> schIds); List<SchSettings> findBySchIds(List<Integer> schIds);
/** 主学校 → 主归属组 → 0(对齐旧 SchSettingsController.resolveSchId) */
Integer resolveSchId(BoaUser user);
/** 主题/头像默认档的目标学校:超管=显式 schId 或 0(平台默认层);其余=当前角色学校(兜底主归属)。
* 原为 SchSettingsController 私有方法,外观偏好轮(2026-10-08)下沉供 AppearanceController 共享。 */
Integer resolveThemeSchId(BoaUser user, Integer explicitSchId);
/** 平台默认层在 sch_settings 中的固定 schId(与 UsedAppController/壁纸/LOGO 的 0 层同口径) */
int PLATFORM_SCH_ID = 0;
/**
* 主题档读侧级联(设置中心三级重构 S7,2026-10-08;oapi 代理轮下沉 service 供 schTheme 读端点与
* /oapi/appearance 合成共享):本校行未配置/旧格式/脏值 → 再查平台默认层(schId=0),
* 命中即返回平台档;两级都无 → value=null。schId 自身即平台层时不二次回落(避免自查两次)。
*/
LayerValue readThemeWithFallback(Integer schId);
/** 头像样式读侧级联:语义与 {@link #readThemeWithFallback} 同构(本校空/脏 → 平台层 → null)。 */
LayerValue readAvatarWithFallback(Integer schId);
/** LOGO 文字读侧级联(LOGO 文字轮 2026-10-09):本校空 → 平台层 → null。 */
LayerValue readLogoTextWithFallback(Integer schId);
/** 默认壁纸读侧级联(壁纸三级默认轮 2026-10-09):本校空 → 平台层 → null。 */
LayerValue readWallpaperWithFallback(Integer schId);
/** 级联读命中结果:{@code value}=命中的档位值(两级都无则 null),{@code hitSchId}=实际命中层的 schId
* (回落发生时是 0,不是解析出的本校 schId)。设置中心读端点的响应体 schId 即此语义——
* 前端据此区分「本校已配置」与「本校未配置、正在回落平台默认」。 */
final class LayerValue {
private final String value;
private final Integer hitSchId;
public LayerValue(String value, Integer hitSchId) {
this.value = value;
this.hitSchId = hitSchId;
}
public String getValue() {
return value;
}
public Integer getHitSchId() {
return hitSchId;
}
}
} }
@@ -0,0 +1,83 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import java.util.List;
import java.util.Optional;
/**
* 小组件候选池服务。
*
* @author chenx
* @date 2026-10-11
*/
public interface IWidgetCatalogService {
/**
* 候选池(登录侧):可见范围命中 + 生效启用 + 角色命中,按生效 sort 升序。
*
* @param schId 当前登录校(用于 scope_type 点名判定与校级覆盖合成)
* @param roleId 当前登录角色 RoleTypeEnum(1 教师 / 2 校领导 / 3 学生),用于 role_types 过滤
* @return 生效值视图(name 已按「校级别名 &gt; 角色命名 &gt; 名称」合成)
*/
List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId);
Optional<WidgetCatalog> findByCode(String code);
/**
* 可见范围判定:scope_type = 0 全放;1 仅点名表命中可见;2 仅点名表未命中可见。
*
* @param catalog 目录行
* @param schId 当前登录校
* @return 命中返回 true
*/
boolean visibleToSchool(WidgetCatalog catalog, Integer schId);
/** 管理端列表(全局原值,含 scopeSchIds 回显),鉴权在 Controller */
List<WidgetCatalog> findAllForAdmin();
/**
* 管理端按视角取列表:超管 = 全局原值;校管 = 本校生效值 + 别名 + 原名 + 覆盖标记。
*
* @param viewerSchId 访问者当前登录校
* @param superAdmin 访问者是否超管(BoaUser.type == 1)
* @return 视角化的目录列表
*/
List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin);
/**
* 保存目录行(仅超管调用),并整体重写 scope_type 的点名表。
*
* @param model 目录行(scopeSchIds 为点名的学校列表)
*/
void save(WidgetCatalog model);
/**
* 删除目录行,连带删点名表与校级覆盖行。
*
* @param id 目录 id
*/
void deleteById(Integer id);
Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId);
/**
* 保存校级覆盖(仅本校)。schId 一律取登录校,忽略入参里的 schId,防越权。
* 四个可覆盖字段全空表示「清除覆盖」,直接删行。
*
* @param model 覆盖行(widgetId 必填)
* @param currentSchId 当前登录校
*/
void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId);
/**
* 显示名三级链:校级别名 &gt; 角色专属命名 &gt; 组件名称。
*
* @param catalog 目录行
* @param schId 当前登录校
* @param roleId 当前登录角色
* @return 最终显示名
*/
String displayName(WidgetCatalog catalog, Integer schId, Integer roleId);
}
@@ -0,0 +1,195 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userhome.config.MsgCenterProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 卡片直批代理(方案 §2,B3):消息卡片上的「同意/拒绝」在 UserHome 服务端完成
* 消息定位 → flow-center 待办匹配 → 办理 → 标已读 全链路。
* <ul>
* <li><b>红线</b>:operatorId / schId / operatorName 一律由控制器从当前 CAS 会话取
* (BoaUser),绝不接受前端传参——防越权代批;flow-center 侧再按任务办理人逐字复核,双层防护。</li>
* <li>taskId 定位(§2.3):我的待办(tasks/todo,assigneeId=登录人)里按 bizId 精确匹配。
* 实测契约:flow-center 待办消息 bizId=办件 id(instanceId),故匹配键 = instanceId,
* bizId(业务记录 id)作为通用回落;bizType 过滤键用消息 jumpApp(=流程业务类型,
* 消息 bizType「flow.task」是消息分类,与待办的 bizType 不同命名空间,勿混)。</li>
* <li>匹配 0 条=已处理或已失效;&gt;1 条(同办件多出口/多节点)=请前往详情页办理(一期不做歧义猜选)。</li>
* <li>出站走 {@link YschOpenCall} 签名直调,目标 flow-center(服务目录/枚举兜底,
* flow-center-base-url 显式覆盖优先)。flow-center 不可达降级为提示,不影响消息卡片其余功能。</li>
* </ul>
*/
@Slf4j
@Service
public class MsgApprovalProxyService {
private static final String TARGET_FLOW_CENTER = "flow-center";
/** 我的待办分页拉取上限:5 页 × 100 条(todo 量级远小于此,防御性封顶) */
private static final int TODO_MAX_PAGES = 5;
private static final int TODO_PAGE_SIZE = 100;
private final YschOpenCall openCall;
private final MsgCenterProxyService msgProxy;
private final MsgCenterProperties properties;
public MsgApprovalProxyService(YschOpenCall openCall, MsgCenterProxyService msgProxy,
MsgCenterProperties properties) {
this.openCall = openCall;
this.msgProxy = msgProxy;
this.properties = properties;
}
/**
* 办理消息对应的审批待办。action=approve|reject;reject 意见必填。
*
* @return {ok, message}——ok=false 时 message 为可直接展示的用户文案
*/
public Map<String, Object> handle(Integer userId, Integer schId, String operatorName,
Long msgId, String action, String opinion) {
Map<String, Object> out = new LinkedHashMap<>();
boolean approve = "approve".equals(action);
if (!approve && !"reject".equals(action)) {
return fail(out, "不支持的操作类型");
}
if (!approve && !StringUtils.hasText(opinion)) {
return fail(out, "拒绝必须填写意见");
}
JsonNode msg = msgProxy.findMessage(userId, msgId);
if (msg == null) {
return fail(out, "消息不存在或已失效");
}
String bizId = text(msg, "bizId");
if (!StringUtils.hasText(bizId)) {
return fail(out, "该消息不含可办理的业务标识");
}
String flowBizType = text(msg, "jumpApp");
List<JsonNode> matched = matchTodoTasks(schId, userId, flowBizType, bizId.trim());
if (matched == null) {
// flow-center 不可达 ≠ 已处理:降级提示,不误导用户
return fail(out, "流程中心暂不可用,请稍后重试");
}
if (matched.isEmpty()) {
return fail(out, "该待办已处理或已失效");
}
if (matched.size() > 1) {
return fail(out, "同一办件匹配到多个待办节点,请前往详情页办理");
}
long taskId = matched.get(0).path("taskId").asLong();
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId == null ? 0 : schId));
query.put("operatorId", String.valueOf(userId));
query.put("operatorName", operatorName == null ? "" : operatorName);
Map<String, Object> body = new HashMap<>();
body.put("opinion", opinion);
postFlowcenter("/oapi/flow/task/" + taskId + "/" + action, query, body);
} catch (DException e) {
// flow-center 业务码拒绝(非办理人/多出口需 edgeName 等)——原文透出给用户
log.info("[msgcenter] 直批被 flow-center 业务拒绝 msgId={} taskId={}:{}", msgId, taskId, e.getMessage());
return fail(out, e.getMessage());
} catch (Exception e) {
log.warn("[msgcenter] flow-center 直批不可达(降级提示)msgId={}:{}", msgId, e.toString());
return fail(out, "流程中心暂不可用,请稍后重试");
}
// 直批后标读:schId 用消息读取口径(当前激活身份学校),保证 UserMsg 侧失效的是本校缓存键
msgProxy.read(userId, java.util.Collections.singletonList(msgId), msgProxy.resolveReadSchId(userId));
out.put("ok", true);
out.put("message", "办理成功");
return out;
}
/**
* 我的待办中精确匹配目标办件:先按 flowBizType(=消息 jumpApp)过滤(未声明则不过滤),
* 再按 instanceId(flow-center 待办消息约定)或 bizId(业务方自发消息约定)匹配。
*
* @return 匹配行(可能为空);flow-center 不可达返回 null(区别于「已处理」的 0 匹配)
*/
private List<JsonNode> matchTodoTasks(Integer schId, Integer userId, String flowBizType, String bizId) {
List<JsonNode> matched = new ArrayList<>();
for (int page = 1; page <= TODO_MAX_PAGES; page++) {
List<JsonNode> content;
try {
content = fetchTodoPage(schId, userId, flowBizType, page);
} catch (Exception e) {
log.warn("[msgcenter] flow-center 待办查询不可达 msgId 定位失败:{}", e.toString());
return null;
}
for (JsonNode task : content) {
String taskBizType = text(task, "bizType");
if (StringUtils.hasText(flowBizType) && !flowBizType.equals(taskBizType)) {
continue;
}
String instanceId = task.has("instanceId") && task.get("instanceId").isNumber()
? String.valueOf(task.get("instanceId").asLong()) : null;
if (bizId.equals(instanceId) || bizId.equals(text(task, "bizId"))) {
matched.add(task);
}
}
if (content.size() < TODO_PAGE_SIZE) {
break;
}
}
return matched;
}
private List<JsonNode> fetchTodoPage(Integer schId, Integer userId, String flowBizType, int page) {
Map<String, String> query = new LinkedHashMap<>();
query.put("schId", String.valueOf(schId == null ? 0 : schId));
query.put("assigneeId", String.valueOf(userId));
if (StringUtils.hasText(flowBizType)) {
query.put("bizType", flowBizType);
}
query.put("page", String.valueOf(page));
query.put("size", String.valueOf(TODO_PAGE_SIZE));
JsonNode data = getFlowcenter("/oapi/flow/tasks/todo", query);
if (data != null && data.path("content").isArray()) {
List<JsonNode> rows = new ArrayList<>();
data.get("content").forEach(rows::add);
return rows;
}
return new ArrayList<>();
}
/** 显式 base-url → ad-hoc TargetSpec(对齐 usermsg 通道形态);否则按名走服务目录/枚举兜底 */
private JsonNode getFlowcenter(String path, Map<String, String> query) {
String base = properties.getFlowCenterBaseUrl();
if (StringUtils.hasText(base)) {
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
}
return openCall.get(TARGET_FLOW_CENTER, path, query, JsonNode.class);
}
private JsonNode postFlowcenter(String path, Map<String, String> query, Object body) {
String base = properties.getFlowCenterBaseUrl();
if (StringUtils.hasText(base)) {
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
}
return openCall.post(TARGET_FLOW_CENTER, path, query, body, JsonNode.class);
}
private static Map<String, Object> fail(Map<String, Object> out, String message) {
out.put("ok", false);
out.put("message", message);
return out;
}
private static String text(JsonNode node, String field) {
return node != null && node.has(field) && !node.get(field).isNull() ? node.get(field).asText() : null;
}
}
@@ -3,32 +3,49 @@ package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.OpenApiMsgClient;
import com.yunhai.platform.openapi.msg.impl.DefaultOpenApiMsgClient;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListItem;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.MsgListQuery;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.HashMap; import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set;
/** /**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调 * UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path), * (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
* UserHome 前端不直连 UserMsg。 * UserHome 前端不直连 UserMsg。
* <ul> * <ul>
* <li>列表/角标:/oapi/msg/list、/oapi/msg/unreadCount、/oapi/msg/todo/list;</li> * <li>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
* <li>已读:/oapi/msg/read、/oapi/msg/readAll(按当前登录人代理,userId 服务端取);</li> * ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
* <li>jumpUrl 改写:jumpType=1 且 jumpPath 非空且 bizType 配了基址 → 基址 + "/#" + jumpPath * ({@link #resolveReadSchId})——按学校隔离读取侧;</li>
* (jumpPath 是裸相对路径如 /task/123,各应用落地页是 hash 路由)。</li> * <li>待办角标 todo/list(仍按主学校 schId,链路口径不变)、单条已读 read、WS 票据
* 继续走 {@link YschOpenCall} 泛化直调;</li>
* <li>jumpUrl 改写:绝对 URL 直通;相对路径按应用注册表取 pc_url——jumpApp(值域=app_code)
* 主查,jumpApp 为空才按发送方 appKey 回落(严格二选一,pc_url 空就是空不猜测,
* 手工基址表 jump-bases 已退役),归一化后拼 "#" + jumpPath;</li>
* <li>showJump 判权(C1):jumpApp 有值时按 /oapi/app/authorized-codes 集合判定,
* 无权 → showJump=false 且 jumpUrl 一并置 null;jumpApp 为空或 UserBase 不可达不判权放行。</li>
* </ul> * </ul>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b> * 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401, * base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为 * 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
* {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。 * {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。
* 目标地址来源:usermsg-base-url 显式配置优先,其次服务目录 usermsg(yh.open.services / 枚举兜底)。 * 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。 * UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/ */
@Slf4j @Slf4j
@@ -36,19 +53,243 @@ import java.util.Map;
public class MsgCenterProxyService { public class MsgCenterProxyService {
private static final String TARGET_USERMSG = "usermsg"; private static final String TARGET_USERMSG = "usermsg";
private static final String TARGET_USERBASE = "userbase";
/** UserBase 查址失败短路窗口:列表逐行查址,下游停摆时避免每行都吃一次连接超时 */
static final long USERBASE_RETRY_MS = 30_000L;
/** 应用字典 TTL:appKey↔appId 映射按用户缓存,到期或失败即重拉/沿用旧值 */
static final long APP_DICT_TTL_MS = 300_000L;
/** 消息定位扫描上限:5 页 × 100 条(UserMsg 无按 id 直查端点,逐页扫描兜底) */
static final int MSG_SCAN_PAGES = 5;
static final int MSG_SCAN_PAGE_SIZE = 100;
private final YschOpenCall openCall; private final YschOpenCall openCall;
private final MsgCenterProperties properties; private final MsgCenterProperties properties;
private final IUserCurrentRoleService userCurrentRoleService;
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties) { /** 0=正常;>0=UserBase 查址失败后的自愈时间戳(System.currentTimeMillis 基准) */
private volatile long userbaseDownUntil;
/** 应用字典按用户缓存(volatile 整体替换,并发重拉无害) */
private final java.util.concurrent.ConcurrentHashMap<Integer, AppDict> appDictCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<Integer, Long> appDictAt = new java.util.concurrent.ConcurrentHashMap<>();
/** 应用字典条目:注册表 appId / appKey / 名称(UserBase BoaApp) */
private static final class AppDictEntry {
final int appId;
final String appKey;
final String name;
AppDictEntry(int appId, String appKey, String name) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
}
}
/** 应用字典:byKey 正查(行富化/出参)+ byId 反查(appId 筛选参数翻译) */
private static final class AppDict {
static final AppDict EMPTY = new AppDict(java.util.Collections.emptyMap(), java.util.Collections.emptyMap());
final Map<String, AppDictEntry> byKey;
final Map<Integer, AppDictEntry> byId;
AppDict(Map<String, AppDictEntry> byKey, Map<Integer, AppDictEntry> byId) {
this.byKey = byKey;
this.byId = byId;
}
}
/**
* by-code 解析条目(UserBase /oapi/app/by-code/{appCode},值域=消息 jump_app)。
* appId=注册表主键 id(2026-10-13 160 实测与 /oapi/app/list 同体系:notice 主键=100001、
* 流程中心主键=100011/app_key=100009,无混体系风险)。
*/
private static final class ByCodeEntry {
final int appId;
final String appKey;
final String name;
final String pcUrl;
final String code;
ByCodeEntry(int appId, String appKey, String name, String pcUrl, String code) {
this.appId = appId;
this.appKey = appKey;
this.name = name;
this.pcUrl = pcUrl;
this.code = code;
}
}
/** 查无哨兵(ConcurrentHashMap 不允许 null 值):脏 code 不重复出站 */
private static final ByCodeEntry CODE_MISS = new ByCodeEntry(-1, null, null, null, null);
/** by-code 缓存本体与时间戳(TTL 对齐 {@link #APP_DICT_TTL_MS}) */
private final java.util.concurrent.ConcurrentHashMap<String, ByCodeEntry> byCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> byCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/**
* by-code 应用缓存(TTL 对齐 {@link #APP_DICT_TTL_MS}):一次调用同时取 name/appId/pcUrl,
* 消掉逐行 by-code 查址的 N+1(旧实现每行现查)。UserBase 不可达复用
* {@link #userbaseDownUntil} 短路窗口;返回 null=不可达(调用方降级),返回
* {@link #CODE_MISS}=查无(回落发送方口径)。
*/
private ByCodeEntry lookupByCode(String appCode) {
if (appCode == null || appCode.isEmpty()) {
return CODE_MISS;
}
Long at = byCodeAt.get(appCode);
boolean fresh = at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS;
if (fresh) {
ByCodeEntry cached = byCodeCache.get(appCode);
return cached == null ? CODE_MISS : cached;
}
if (userbaseDown()) {
ByCodeEntry stale = byCodeCache.get(appCode);
return stale == null ? CODE_MISS : stale;
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-code/" + appCode, null, JsonNode.class);
userbaseDownUntil = 0L;
ByCodeEntry entry = app != null && app.hasNonNull("id")
? new ByCodeEntry(app.get("id").asInt(),
app.hasNonNull("appKey") ? String.valueOf(app.get("appKey").asInt()) : null,
text(app, "name"), text(app, "pcUrl"), text(app, "appCode"))
: CODE_MISS;
byCodeCache.put(appCode, entry);
byCodeAt.put(appCode, System.currentTimeMillis());
return entry;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-code 解析不可达(降级回落发送方口径)code={}:{}", appCode, e.toString());
return null;
}
}
/** appKey→app_code 反查缓存(list 的 appId 双值翻译用;空串值=查无,退化为单值过滤) */
private final java.util.concurrent.ConcurrentHashMap<String, String> appCodeCache = new java.util.concurrent.ConcurrentHashMap<>();
private final java.util.concurrent.ConcurrentHashMap<String, Long> appCodeAt = new java.util.concurrent.ConcurrentHashMap<>();
/** appKey→app_code 反查(/oapi/app/by-key,VO 含 appCode):首次查询后缓存,查无/不可达返回 null */
private String appCodeByKey(String appKey) {
if (appKey == null || appKey.isEmpty()) {
return null;
}
Long at = appCodeAt.get(appKey);
if (at != null && System.currentTimeMillis() - at < APP_DICT_TTL_MS) {
return appCodeCache.get(appKey);
}
if (appCodeCache.containsKey(appKey) && userbaseDown()) {
return appCodeCache.get(appKey);
}
try {
JsonNode app = openCall.get(TARGET_USERBASE, "/oapi/app/by-key/" + appKey, null, JsonNode.class);
userbaseDownUntil = 0L;
String code = app == null || app.isNull() ? null : text(app, "appCode");
appCodeCache.put(appKey, code == null ? "" : code);
appCodeAt.put(appKey, System.currentTimeMillis());
return StringUtils.hasText(code) ? code : null;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase by-key 反查 app_code 不可达(退化单值过滤)appKey={}:{}", appKey, e.toString());
return null;
}
}
/**
* 应用字典(UserBase /oapi/app/list 按用户授权全量,TTL {@link #APP_DICT_TTL_MS})。
* 失败/短路窗口内沿用旧字典(无旧值用空);出参不含 appSecret,仅取 id/appKey/name。
*/
private AppDict appDict(Integer userId) {
if (userId == null) {
return AppDict.EMPTY;
}
AppDict cached = appDictCache.get(userId);
Long at = appDictAt.get(userId);
long now = System.currentTimeMillis();
if (cached != null && at != null && now - at < APP_DICT_TTL_MS) {
return cached;
}
if (userbaseDown()) {
return cached != null ? cached : AppDict.EMPTY;
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
JsonNode groups = openCall.get(TARGET_USERBASE, "/oapi/app/list", q, JsonNode.class);
userbaseDownUntil = 0L;
Map<String, AppDictEntry> byKey = new LinkedHashMap<>();
Map<Integer, AppDictEntry> byId = new LinkedHashMap<>();
if (groups != null && groups.isArray()) {
for (JsonNode group : groups) {
JsonNode apps = group.path("apps");
if (!apps.isArray()) {
continue;
}
for (JsonNode app : apps) {
if (app == null || !app.hasNonNull("id") || !app.hasNonNull("appKey")) {
continue;
}
AppDictEntry entry = new AppDictEntry(app.get("id").asInt(),
app.get("appKey").asText(), text(app, "name"));
byKey.put(entry.appKey, entry);
byId.put(entry.appId, entry);
}
}
}
AppDict dict = new AppDict(byKey, byId);
appDictCache.put(userId, dict);
appDictAt.put(userId, now);
return dict;
} catch (Exception e) {
userbaseDownUntil = now + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 应用字典不可达({}ms 内沿用旧字典/空)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return cached != null ? cached : AppDict.EMPTY;
}
}
public MsgCenterProxyService(YschOpenCall openCall, MsgCenterProperties properties,
IUserCurrentRoleService userCurrentRoleService) {
this.openCall = openCall; this.openCall = openCall;
this.properties = properties; this.properties = properties;
this.userCurrentRoleService = userCurrentRoleService;
} }
private boolean enabled() { private boolean enabled() {
return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG); return StringUtils.hasText(properties.getUsermsgBaseUrl()) || openCall.hasTarget(TARGET_USERMSG);
} }
/**
* 消息读取口径 schId(按学校隔离读取侧,R2):取<b>当前激活身份</b>所属学校
* ({@code userCurrentRoleService.findById} → UserBase /oapi/identity/current),
* <b>严禁</b>用会话主学校(跨校切换会错)。解析为 0(平台/超管)或身份链不可达时返回 null
* =不下发过滤(看全部,D5,且与改造前行为一致)。
*/
public Integer resolveReadSchId(Integer userId) {
if (userId == null) {
return null;
}
try {
return userCurrentRoleService.findById(userId)
.map(UserCurrentRole::getSchId)
.filter(schId -> schId != null && schId > 0)
.orElse(null);
} catch (Exception e) {
log.warn("[msgcenter] 当前身份学校解析不可达(降级不过滤=全量口径)userId={}:{}", userId, e.toString());
return null;
}
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx);否则类型化读方法按服务目录 usermsg 寻址 */
private OpenApiMsgClient msgClient() {
String base = properties.getUsermsgBaseUrl();
return StringUtils.hasText(base)
? new DefaultOpenApiMsgClient(openCall, TargetSpec.fromUrl(base.trim()))
: new DefaultOpenApiMsgClient(openCall);
}
/** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */ /** 显式 base-url → ad-hoc TargetSpec(path 段即目标 ctx,per-target 签名剥离);否则按名走服务目录 */
private JsonNode get(String path, Map<String, String> query) { private JsonNode get(String path, Map<String, String> query) {
String base = properties.getUsermsgBaseUrl(); String base = properties.getUsermsgBaseUrl();
@@ -66,27 +307,53 @@ public class MsgCenterProxyService {
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class); return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
} }
/** 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的 jumpUrl */ /**
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread) { * 消息分页列表(已过滤删除/过期):{total, list[]},元素含改写后的绝对 jumpUrl 与 showJump 判权出参。
* 可选筛选(null/空白不透传,旧 UserMsg 收到未知参数会忽略——不传时行为与历史版本一致):
* msgType 0/1/2、appId(注册表应用 id,代理翻译为发送方 appKey 后下行)、isRead 0/1、keyword 标题/内容模糊。
* schId=当前身份学校口径({@link #resolveReadSchId}),null=不过滤(平台/超管视角,看全部)。
*/
public Map<String, Object> list(Integer userId, Integer pageIndex, Integer pageSize, Boolean onlyUnread,
Integer msgType, String appId, Integer isRead, String keyword, Integer schId) {
Map<String, Object> empty = emptyPage(); Map<String, Object> empty = emptyPage();
if (!enabled()) { if (!enabled()) {
return empty; return empty;
} }
PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId);
try { try {
Map<String, String> query = new LinkedHashMap<>(); MsgListQuery query = new MsgListQuery();
query.put("userId", String.valueOf(userId)); query.setUserId(userId);
query.put("pageIndex", String.valueOf(pageIndex == null || pageIndex < 1 ? 1 : pageIndex)); query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
query.put("pageSize", String.valueOf(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100))); query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
query.put("onlyUnread", String.valueOf(Boolean.TRUE.equals(onlyUnread))); query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
JsonNode data = get("/oapi/msg/list", query); query.setMsgType(msgType);
if (StringUtils.hasText(appId)) {
AppDictEntry entry = dict.byId.get(Integer.valueOf(appId.trim()));
if (entry == null) {
return empty; // 字典查无此应用:视作该应用无消息
}
// 归属口径双值过滤:该应用自发行桶值=app_key,流程中心代发行桶值=app_code
List<String> appKeys = new ArrayList<>();
appKeys.add(entry.appKey);
String code = appCodeByKey(entry.appKey);
if (StringUtils.hasText(code) && !code.equals(entry.appKey)) {
appKeys.add(code);
}
query.setAppKeys(appKeys);
}
query.setIsRead(isRead);
query.setKeyword(StringUtils.hasText(keyword) ? keyword.trim() : null);
query.setSchId(schId);
MsgListPage page = msgClient().list(query);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (data != null && data.path("content").isArray()) { if (page != null && page.getContent() != null) {
for (JsonNode row : data.get("content")) { for (MsgListItem item : page.getContent()) {
rows.add(rewrite(row)); rows.add(rewrite(item, authz, dict));
} }
} }
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("total", data == null || !data.has("total") ? 0 : data.get("total").asInt()); out.put("total", page == null ? 0 : page.getTotal());
out.put("list", rows); out.put("list", rows);
return out; return out;
} catch (Exception e) { } catch (Exception e) {
@@ -95,34 +362,60 @@ public class MsgCenterProxyService {
} }
} }
/** 铃铛摘要:未读数 + 各应用待办角标(停机各自降级 0/空) */ /**
public Map<String, Object> summary(Integer userId, Integer schId) { * 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 0/空)。
* 分类计数(sysUnread/bizUnread/approvalUnread/total/readCount)来自 UserMsg /oapi/msg/summary
* (消息中心左栏一次喂饱),按 msgSchId 学校口径取数;UserMsg 不可达/字段缺省时降级 unreadCount 单查。
*
* @param msgSchId 消息读取口径(当前激活身份学校,null=全部)
* @param todoSchId 待办角标口径(沿用会话主学校,链路口径不变,见类注释)
*/
public Map<String, Object> summary(Integer userId, Integer msgSchId, Integer todoSchId) {
Map<String, Object> out = new HashMap<>(); Map<String, Object> out = new HashMap<>();
out.put("unreadCount", 0L); out.put("unreadCount", 0L);
out.put("todos", new ArrayList<>()); out.put("todos", new ArrayList<>());
if (!enabled()) { if (!enabled()) {
return out; return out;
} }
boolean summaryFetched = false;
try { try {
Map<String, String> q = new LinkedHashMap<>(); UserMsgSummary vo = msgClient().summary(userId, msgSchId);
q.put("userId", String.valueOf(userId)); if (vo != null) {
JsonNode unread = get("/oapi/msg/unreadCount", q); out.put("unreadCount", vo.getUnreadCount());
out.put("unreadCount", unread == null || !unread.isNumber() ? 0L : unread.asLong()); out.put("sysUnread", vo.getSysUnread());
out.put("bizUnread", vo.getBizUnread());
out.put("approvalUnread", vo.getApprovalUnread());
out.put("total", vo.getTotal());
out.put("readCount", vo.getReadCount());
// 分类总数(含已读)——左栏计数与列表内容对齐的浏览口径
out.put("sysTotal", vo.getSysTotal());
out.put("bizTotal", vo.getBizTotal());
out.put("approvalTotal", vo.getApprovalTotal());
summaryFetched = true;
}
} catch (Exception e) {
// 旧版 UserMsg 无 summary 端点:降级走 unreadCount 单查,字段缺省由前端兜底
log.debug("[msgcenter] UserMsg 摘要端点不可用(降级未读单查)userId={}:{}", userId, e.toString());
}
if (!summaryFetched) {
try {
out.put("unreadCount", msgClient().unreadCount(userId, msgSchId));
} catch (Exception e) { } catch (Exception e) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString()); log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
} }
}
try { try {
Map<String, String> q = new LinkedHashMap<>(); Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId)); q.put("userId", String.valueOf(userId));
if (schId != null) { if (todoSchId != null) {
q.put("schId", String.valueOf(schId)); q.put("schId", String.valueOf(todoSchId));
} }
JsonNode todos = get("/oapi/msg/todo/list", q); JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>(); List<Map<String, Object>> rows = new ArrayList<>();
if (todos != null && todos.isArray()) { if (todos != null && todos.isArray()) {
for (JsonNode t : todos) { for (JsonNode t : todos) {
Map<String, Object> row = new LinkedHashMap<>(); Map<String, Object> row = new LinkedHashMap<>();
row.put("appKey", text(t, "appKey")); // appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
row.put("count", t.has("count") ? t.get("count").asInt() : 0); row.put("count", t.has("count") ? t.get("count").asInt() : 0);
rows.add(row); rows.add(row);
} }
@@ -134,25 +427,133 @@ public class MsgCenterProxyService {
return out; return out;
} }
/** 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪) */ /**
public void read(Integer userId, List<Long> msgIds) { * 按应用消息计数(UserMsg /oapi/msg/countByApp),条目富化注册表 appId/appName:
* [{appId, appName, total, unread}]——消息中心「按应用」清单角标(总数口径,前端隐藏 0 行)。
* UserMsg 桶值为归属口径 code-or-key 混合(代发=业务方 app_code、自发=app_key):
* 逐桶先 byCode 解析、未命中回落字典 byKey(发送方),解析出应用后按 appId 合并 total/unread
* (同一应用的自发桶与代发货桶并成一类目)。双查无的桶跳过(与旧「字典查无即丢」一致)。
* schId=当前身份学校口径(null=全部)。停机降级空列表。
*/
public List<Map<String, Object>> countByApp(Integer userId, Integer schId) {
List<Map<String, Object>> empty = new ArrayList<>();
if (!enabled()) {
return empty;
}
try {
List<AppMsgCount> counts = msgClient().countByApp(userId, schId);
if (counts == null || counts.isEmpty()) {
return empty;
}
AppDict dict = appDict(userId);
Map<Object, Map<String, Object>> merged = new LinkedHashMap<>();
for (AppMsgCount a : counts) {
String bucket = a == null ? null : a.getAppKey();
if (bucket == null || bucket.isEmpty()) {
continue;
}
AppDictEntry entry = dict.byKey.get(bucket);
// 纯数字桶值不可能是 app_code,免一次注定查无的出站;
// 数字脏桶(字典查无)维持现状——行保留、appId=null 由前端隐藏
ByCodeEntry codeEntry = entry == null && !bucket.matches("\\d+")
? lookupByCode(bucket) : null;
Integer appId;
String name;
if (entry != null) {
appId = entry.appId;
name = entry.name;
} else if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
name = codeEntry.name;
} else if (codeEntry == CODE_MISS) {
continue; // code 脏桶双查无:丢弃,与改造前一致
} else {
appId = null;
name = properties.getAppNames().get(bucket);
}
// 合桶键=注册表主键 appId:自发桶(byKey)与代发桶(byCode)同应用并成一类目
Map<String, Object> row = merged.get(appId);
if (row == null) {
row = new LinkedHashMap<>();
row.put("appId", appId);
row.put("appName", name);
row.put("total", 0L);
row.put("unread", 0L);
merged.put(appId, row);
}
row.put("total", (Long) row.get("total") + a.getTotal());
row.put("unread", (Long) row.get("unread") + a.getUnread());
}
List<Map<String, Object>> out = new ArrayList<>(merged.values());
out.sort((x, y) -> {
int c = Long.compare((Long) y.get("total"), (Long) x.get("total"));
return c != 0 ? c : Long.compare((Long) y.get("unread"), (Long) x.get("unread"));
});
return out;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 按应用计数不可达(降级空)userId={}:{}", userId, e.toString());
return empty;
}
}
/**
* 标记已读(代理按登录人发送,UserMsg 侧 UPDATE 带归属人防伪)。
* schId 仅用于 UserMsg 侧缓存键(含学校口径,R1)与 readState 帧口径对齐,不参与 UPDATE 归属判定。
*/
public void read(Integer userId, List<Long> msgIds, Integer schId) {
if (!enabled() || msgIds == null || msgIds.isEmpty()) { if (!enabled() || msgIds == null || msgIds.isEmpty()) {
return; return;
} }
Map<String, Object> body = new HashMap<>(); Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds); body.put("msgIds", msgIds);
body.put("userId", userId); body.put("userId", userId);
if (schId != null) {
body.put("schId", schId);
}
post("/oapi/msg/read", new HashMap<>(), body); post("/oapi/msg/read", new HashMap<>(), body);
} }
/** 全部已读 */ /** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
public void readAll(Integer userId) { public void readAll(Integer userId, Integer schId) {
if (!enabled()) { if (!enabled()) {
return; return;
} }
Map<String, Object> body = new HashMap<>(); msgClient().readAll(userId, schId);
body.put("userId", userId); }
post("/oapi/msg/readAll", new HashMap<>(), body);
/**
* 按 msgId 定位当前登录人的消息(卡片直批 B3 用)。归属校验=按登录人分页拉列表后命中:
* 他人消息/已删除/已过期一律查无,统一返回 null(不区分原因,防越权探测)。
*/
public JsonNode findMessage(Integer userId, Long msgId) {
if (!enabled() || msgId == null) {
return null;
}
for (int page = 1; page <= MSG_SCAN_PAGES; page++) {
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId));
query.put("pageIndex", String.valueOf(page));
query.put("pageSize", String.valueOf(MSG_SCAN_PAGE_SIZE));
JsonNode data = get("/oapi/msg/list", query);
if (data == null || !data.path("content").isArray()) {
return null;
}
for (JsonNode row : data.get("content")) {
if (row.has("msgId") && row.get("msgId").asLong() == msgId) {
return row;
}
}
if (data.get("content").size() < MSG_SCAN_PAGE_SIZE
|| data.path("total").asLong() <= (long) page * MSG_SCAN_PAGE_SIZE) {
return null;
}
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 消息定位不可达 msgId={}:{}", msgId, e.toString());
return null;
}
}
return null;
} }
/** /**
@@ -182,45 +583,196 @@ public class MsgCenterProxyService {
// ===== 出参改写 ===== // ===== 出参改写 =====
/** MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且配置了 bizType 基址时)+ 应用名补全 */ /**
private Map<String, Object> rewrite(JsonNode row) { * MsgListVo 逐字段透传 + jumpUrl 改写(jumpType=1 且查到基址时)+ showJump 判权 + 应用信息补全。
* 应用标识对前端只出 appId/appName:归属换源(页面属主优先)——jumpApp(值域=app_code)可解析时
* 出参指向业务方应用,为空/查无/不可达回落发送方 appKey(字典 byKey,再落手工名称表)。
* appKey 属后端签名标识不下发;两口径均查无时 appId/appName 为 null,前端按类型标签降级。
*/
private Map<String, Object> rewrite(MsgListItem row, PerRequestAuthz authz, AppDict dict) {
Map<String, Object> out = new LinkedHashMap<>(); Map<String, Object> out = new LinkedHashMap<>();
out.put("msgId", row.has("msgId") ? row.get("msgId").asLong() : null); out.put("msgId", row.getMsgId());
out.put("title", text(row, "title")); out.put("title", row.getTitle());
out.put("content", text(row, "content")); out.put("content", row.getContent());
out.put("msgType", row.has("msgType") && row.get("msgType").isNumber() ? row.get("msgType").asInt() : null); out.put("msgType", row.getMsgType());
out.put("jumpType", row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null); out.put("jumpType", row.getJumpType());
out.put("jumpPath", text(row, "jumpPath")); out.put("jumpPath", row.getJumpPath());
out.put("appKey", text(row, "appKey")); out.put("jumpApp", row.getJumpApp());
String appKey = text(row, "appKey"); // 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
out.put("appName", appKey == null ? null : properties.getAppNames().get(appKey)); // jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
out.put("bizType", text(row, "bizType")); // 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
out.put("bizId", text(row, "bizId")); String jumpApp = row.getJumpApp();
out.put("sendTime", text(row, "sendTime")); String ownerCode = jumpApp == null ? null : jumpApp.trim();
out.put("isRead", row.has("isRead") && row.get("isRead").isNumber() ? row.get("isRead").asInt() : 0); AppDictEntry entry = null;
out.put("jumpUrl", jumpUrl(row)); Integer appId = null;
String appName = null;
if (StringUtils.hasText(ownerCode)) {
ByCodeEntry codeEntry = lookupByCode(ownerCode);
if (codeEntry != null && codeEntry != CODE_MISS) {
appId = codeEntry.appId;
appName = codeEntry.name;
}
}
if (appId == null) {
String appKey = row.getAppKey();
entry = appKey == null ? null : dict.byKey.get(appKey);
if (entry != null) {
appId = entry.appId;
appName = entry.name;
} else {
appName = properties.getAppNames().get(appKey);
}
}
out.put("appId", appId);
out.put("appName", appName);
out.put("bizType", row.getBizType());
out.put("bizId", row.getBizId());
out.put("sendTime", row.getSendTime());
out.put("isRead", row.getIsRead() == null ? 0 : row.getIsRead());
String jumpUrl = jumpUrl(row);
// showJump(C1):jumpApp 有值才判权;无权 → false 且 jumpUrl 一并置 null(双保险防前端漏改);
// jumpApp 为空 / UserBase 不可达(判权结果 null)→ 不判权放行
Boolean allowed = null;
if (jumpUrl != null && StringUtils.hasText(ownerCode)) {
allowed = authz.authorized(ownerCode);
}
boolean showJump = jumpUrl != null && !Boolean.FALSE.equals(allowed);
if (Boolean.FALSE.equals(allowed)) {
jumpUrl = null;
}
out.put("jumpUrl", jumpUrl);
out.put("showJump", showJump);
return out; return out;
} }
/** 基址 + "/#" + jumpPath;未配置基址/非应用内跳转/无路径 → null(前端不渲染跳转) */ /**
private String jumpUrl(JsonNode row) { * 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时——
Integer jumpType = row.has("jumpType") && row.get("jumpType").isNumber() ? row.get("jumpType").asInt() : null; * <ol>
String jumpPath = text(row, "jumpPath"); * <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
String bizType = text(row, "bizType"); * <li>应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果;
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath) * jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。
|| !StringUtils.hasText(bizType)) { * pc_url 按 §3.2 归一化(丢 fragment、去尾斜杠、保留 index.htm(l))后拼 "#";</li>
* <li>查无 pc_url → null(前端不渲染跳转入口)——pc_url 空就是空,不猜测、不再回落发送方首页。</li>
* </ol>
* 注册表是多端地址(pc/app/小程序/钉钉)唯一来源;jump-bases 手工基址表已退役(C2)。
*/
private String jumpUrl(MsgListItem row) {
Integer jumpType = row.getJumpType();
String jumpPath = row.getJumpPath();
String jumpApp = row.getJumpApp();
if (jumpType == null || jumpType != 1 || !StringUtils.hasText(jumpPath)) {
return null; return null;
} }
String base = properties.getJumpBases().get(bizType); String path = jumpPath.trim();
if (!StringUtils.hasText(base)) { if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (userbaseDown()) {
return null; return null;
} }
String b = base.trim(); String p = path.startsWith("/") ? path : "/" + path;
while (b.endsWith("/")) { String appKey = row.getAppKey();
b = b.substring(0, b.length() - 1); String pcUrl = StringUtils.hasText(jumpApp) ? pcUrlByCode(jumpApp.trim())
: (StringUtils.hasText(appKey) ? pcUrlByKey(appKey.trim()) : null);
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);
}
return base + "#" + p;
}
/**
* 注册表 pc_url 查询(by-code 主查):走 by-code 缓存,一次调用同取 name 与 pcUrl
* (消掉旧逐行现查的 N+1)。不可达短路窗口内沿用已缓存值(旧 pcUrl 拼接语义不变,
* 查不到/无缓存才隐藏跳转入口)。
*/
private String pcUrlByCode(String appCode) {
ByCodeEntry entry = lookupByCode(appCode.trim());
return entry == null || entry == CODE_MISS ? null : entry.pcUrl;
}
/** 注册表 pc_url 查询(发送方 appKey 回落,appKey 是 app_key 列非主键 id) */
private String pcUrlByKey(String appKey) {
return lookupPcUrl("/oapi/app/by-key/" + appKey);
}
private String lookupPcUrl(String path) {
try {
JsonNode app = openCall.get(TARGET_USERBASE, path, null, JsonNode.class);
boolean ok = app != null && app.hasNonNull("pcUrl") && StringUtils.hasText(app.get("pcUrl").asText());
userbaseDownUntil = 0L;
return ok ? app.get("pcUrl").asText() : null;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 查址不可达({}ms 内跳转入口整体隐藏)path={}:{}", USERBASE_RETRY_MS, path, e);
return null;
}
}
private boolean userbaseDown() {
return System.currentTimeMillis() < userbaseDownUntil;
}
/**
* 单次请求内的判权懒加载器:列表内首个带 jumpApp 的行触发一次出站,之后行间共用
* (禁按行重复出站);UserBase 不可达 → 判权结果 null(放行),失败进 30s 短路窗口。
*/
private class PerRequestAuthz {
private final Integer userId;
private boolean fetched;
private Set<String> codes;
PerRequestAuthz(Integer userId) {
this.userId = userId;
}
/** @return true=有权 false=无权 null=不可判(UserBase 不可达)→ 调用方放行 */
Boolean authorized(String appCode) {
if (!fetched) {
fetched = true;
codes = fetchAuthorizedCodes(userId);
}
return codes == null ? null : codes.contains(appCode);
}
}
/**
* 拉当前用户有权应用的 app_code 集合(UserBase /oapi/app/authorized-codes)。
* 复用 userbaseDownUntil 30s 短路:短路中不出站;请求失败同样进短路窗口。
*
* @return app_code 集合;UserBase 不可达 → null(调用方不判权放行,绝不批量隐藏)
*/
private Set<String> fetchAuthorizedCodes(Integer userId) {
if (userId == null || userbaseDown()) {
return null;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userId", String.valueOf(userId));
JsonNode data = openCall.get(TARGET_USERBASE, "/oapi/app/authorized-codes", query, JsonNode.class);
userbaseDownUntil = 0L;
Set<String> codes = new HashSet<>();
if (data != null && data.isArray()) {
for (JsonNode n : data) {
if (n != null && n.isTextual() && StringUtils.hasText(n.asText())) {
codes.add(n.asText().trim());
}
}
}
return codes;
} catch (Exception e) {
userbaseDownUntil = System.currentTimeMillis() + USERBASE_RETRY_MS;
log.warn("[msgcenter] UserBase 判权不可达({}ms 内不判权放行)userId={}:{}",
USERBASE_RETRY_MS, userId, e.toString());
return null;
} }
String path = jumpPath.startsWith("/") ? jumpPath : "/" + jumpPath;
return b + "/#" + path;
} }
private static Map<String, Object> emptyPage() { private static Map<String, Object> emptyPage() {
@@ -0,0 +1,281 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.yunhai.platform.openapi.core.YschOpenCall;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* 小组件统一取数。
*
* <p>{@code syllabus}(我的课表)已对接 AcaSyllabu {@code GET /oapi/timetable/Get}(出站走 {@link YschOpenCall},
* 带 yh-app-auth 签名;目标 {@code acasyllabu} 配于 {@code yh.call.targets}),**原样透传** {@code data[]}
* 班级数组(含 {@code timetable.slots[].subjects[7]}),由前端按班级维度渲染。</p>
*
* <p>{@code 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
public class WidgetDataService {
private static final Logger log = LoggerFactory.getLogger(WidgetDataService.class);
private static final String CODE_EVALUATE = "evaluate";
private static final String CODE_SYLLABUS = "syllabus";
private static final String CODE_WEEKPLAN = "weekplan";
/** 听评课旧接口 pageSize 默认值(evaluate_index.js 传 count=5) */
private static final int EVALUATE_DEFAULT_COUNT = 5;
/** 课表 oapi 目标名(见 yh.call.targets.acasyllabu) */
private static final String TARGET_ACASYLLABU = "acasyllabu";
/** 课表 oapi 路径(签名按目标 ctx 剥离,故此处不含 /acasyllabu) */
private static final String PATH_TIMETABLE = "/oapi/timetable/Get";
/** 课表缓存时长:课表低频变更,避免每次进桌面都打出站 */
private static final long SYLLABUS_TTL_MS = 90_000L;
/** 课表取数失败后的静默期(短 TTL 空结果,避免下游停摆时持续打出站) */
private static final long SYLLABUS_FAIL_BACKOFF_MS = 15_000L;
/** 课表缓存条目上限,超出时清理过期项 */
private static final int SYLLABUS_CACHE_MAX = 500;
/** 一周安排 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 口径)。
*
* @return 数据结构由数据键决定;null = 该键未接入
*/
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(caller, params);
log.warn("[widget] 未配置的数据键:{}", code);
return null;
}
/**
* 听评课:分页 {list, count, pageSize},入参 {page, count}(page 从 0 起)。
* // TODO 接入 oapi: openCall.post(TARGET_USERBASE, "/oapi/...", null, body, Vo.class)
*/
private Map<String, Object> evaluate(String params) {
int page = Math.max(0, intParam(params, "page", 0));
int count = Math.min(50, Math.max(1, intParam(params, "count", EVALUATE_DEFAULT_COUNT)));
List<Map<String, Object>> rows = new ArrayList<>();
rows.add(row("id", 101, "title", "《圆的认识》课堂观察",
"lectureTime", "2026-09-28 08:30:00", "lectureteaName", "王晓梅", "subName", "数学"));
rows.add(row("id", 102, "title", "《Unit 5 Reading》公开课",
"lectureTime", "2026-09-29 10:00:00", "lectureteaName", "李强", "subName", "英语"));
rows.add(row("id", 103, "title", "《光的折射》实验课",
"lectureTime", "2026-09-30 14:00:00", "lectureteaName", "陈雷", "subName", "物理"));
rows.add(row("id", 104, "title", "《三角形内角和》研讨课",
"lectureTime", "2026-10-09 09:00:00", "lectureteaName", "赵敏", "subName", "数学"));
int from = Math.min(page * count, rows.size());
int to = Math.min(from + count, rows.size());
Map<String, Object> result = new LinkedHashMap<>();
result.put("list", rows.subList(from, to));
result.put("count", rows.size());
result.put("pageSize", count);
return result;
}
/**
* 我的课表:AcaSyllabu {@code GET /oapi/timetable/Get?userid=&roleId=&schId=}(SDK 出站,签名自动)。
* 原样返回 {@code data[]} 班级数组;无可见范围返回 {@code []};不可达 / 验签失败降级空数组(不抛 500)。
*
* <p>身份三参均来自服务端会话推导({@link Caller}),不接受前端传入;roleId&lt;=0 时不透传
* (oapi 侧默认按普通教师处理)。</p>
*/
private Object syllabus(Caller caller) {
if (caller == null || caller.getUserId() == null || caller.getSchId() == null) {
// 缺身份(超管无当前身份等)→ 空数据,不打出站
return Collections.emptyList();
}
String key = caller.getUserId() + "|" + caller.getRoleId() + "|" + caller.getSchId();
long now = System.currentTimeMillis();
CacheEntry hit = syllabusCache.get(key);
if (hit != null && now < hit.expireAt) {
return hit.data;
}
try {
Map<String, String> query = new LinkedHashMap<>();
query.put("userid", String.valueOf(caller.getUserId()));
if (caller.getRoleId() != null && caller.getRoleId() > 0) {
query.put("roleId", String.valueOf(caller.getRoleId()));
}
query.put("schId", String.valueOf(caller.getSchId()));
JsonNode data = openCall.get(TARGET_ACASYLLABU, PATH_TIMETABLE, query, JsonNode.class);
Object value = (data != null && data.isArray()) ? data : Collections.emptyList();
cache(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(syllabusCache, SYLLABUS_CACHE_MAX, key, Collections.emptyList(), now + SYLLABUS_FAIL_BACKOFF_MS, now);
return Collections.emptyList();
}
}
/**
* 一周安排: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 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));
}
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();
}
}
/** 写缓存并做上限清理(只清过期项,避免无界增长) */
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) {
store.remove(entry.getKey(), entry.getValue());
}
}
}
store.put(key, new CacheEntry(value, expireAt));
}
private int intParam(String params, String key, int defaultValue) {
if (params == null || params.isEmpty()) return defaultValue;
for (String pair : params.split("&")) {
int split = pair.indexOf('=');
if (split <= 0 || !key.equals(pair.substring(0, split))) continue;
try {
return Integer.parseInt(URLDecoder.decode(pair.substring(split + 1), "UTF-8").trim());
} catch (NumberFormatException | UnsupportedEncodingException e) {
return defaultValue;
}
}
return defaultValue;
}
private Map<String, Object> row(Object... keyValues) {
Map<String, Object> map = new LinkedHashMap<>();
for (int i = 0; i + 1 < keyValues.length; i += 2) {
map.put(String.valueOf(keyValues[i]), keyValues[i + 1]);
}
return map;
}
/** 取数调用方上下文:三参均会话推导,不接受前端传入 */
public static final class Caller {
private final Integer userId;
private final Integer roleId;
private final Integer schId;
public Caller(Integer userId, Integer roleId, Integer schId) {
this.userId = userId;
this.roleId = roleId;
this.schId = schId;
}
public Integer getUserId() {
return userId;
}
public Integer getRoleId() {
return roleId;
}
public Integer getSchId() {
return schId;
}
}
/** 课表缓存条目 */
private static final class CacheEntry {
private final Object data;
private final long expireAt;
private CacheEntry(Object data, long expireAt) {
this.data = data;
this.expireAt = expireAt;
}
}
}
@@ -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();
}
}
@@ -1,82 +0,0 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userhome.entity.AppCategory;
import com.yunhai.userhome.repository.AppCategoryRepository;
import com.yunhai.userhome.service.IAppCategoryService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.Optional;
/**
* 应用类别服务(自 systemsetting AppCategoryService 1:1 迁移,GetAppCategorySel/getTimestampByIds 无消费方不迁)。
*/
@Service
public class AppCategoryServiceImpl implements IAppCategoryService {
@Autowired
private AppCategoryRepository appCategoryRepository;
@Override
public void save(AppCategory appCategory) {
appCategoryRepository.save(appCategory);
}
@Override
public void deleteById(Integer id) {
if (!appCategoryRepository.existsById(id)) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
}
appCategoryRepository.deleteById(id);
}
@Override
public Optional<AppCategory> findById(Integer id) {
return appCategoryRepository.findById(id);
}
@Override
public List<AppCategory> findByRoleType(Integer roleType) {
return appCategoryRepository.findAllByRoleType(roleType);
}
@SuppressWarnings({"rawtypes", "unchecked"})
@Override
public Page<AppCategory> findPageList(Integer page, Integer count, Integer roleType, String name) {
Sort sort = Sort.by(Sort.Direction.DESC, "id");
Pageable pageable = PageRequest.of(page - 1, count, sort);
Specification specification = (Specification) (root, criteriaQuery, criteriaBuilder) -> {
List<Predicate> predicates = new ArrayList<>();
if (roleType != null) {
predicates.add(criteriaBuilder.equal(root.get("roleType"), roleType));
}
if (name != null && !name.trim().isEmpty()) {
predicates.add(criteriaBuilder.like(root.get("name"), "%" + name + "%"));
}
return criteriaBuilder.and(predicates.toArray(new Predicate[predicates.size()]));
};
return appCategoryRepository.findAll(specification, pageable);
}
@Override
public void updateResDate(Integer id) {
Optional<AppCategory> optionalAppCategory = findById(id);
if (!optionalAppCategory.isPresent()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
}
AppCategory model = optionalAppCategory.get();
model.setResDate(new Date());
appCategoryRepository.save(model);
}
}
@@ -0,0 +1,214 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.Logo;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserAppearance;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.repository.LogoRepository;
import com.yunhai.userhome.repository.UserAppearanceRepository;
import com.yunhai.userhome.service.IAppearanceService;
import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.ThemeCodec;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 用户外观偏好聚合实现(外观偏好服务端化 2026-10-08)。
*
* <p>消费方是各子应用 init 的异步补齐段(挂载后 fetch,失败静默回落默认观感),因此本服务
* 对外承诺:聚合读永不抛异常——个人段/学校段各自独立降级(一段故障不影响另一段),
* 查不到/脏值/依赖故障的字段一律 null。</p>
*
* <p>数据来源:myTheme/avatarMy ← user_appearance(纯本地,per-user 一行);
* schTheme/avatarSch/logoUrl ← sch_settings + logo 表(schId 复用 resolveThemeSchId——
* 超管=平台层 0,其余=当前角色学校;identity 跨仓查询失败时学校段整体降级 null)。
* Logo 择优复用 UserSettingsServiceImpl.findLogoList 口径:本校 type=1 优先 → 系统 type=0 → null。</p>
*/
@Service
public class AppearanceServiceImpl implements IAppearanceService {
@Autowired
private UserAppearanceRepository userAppearanceRepository;
@Autowired
private ISchSettingsService schSettingsService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
@Autowired
private LogoRepository logoRepository;
@Value("${FILE_WEB_PATH:}")
private String fileWebPath;
@Override
public Map<String, Object> getAppearance(BoaUser user) {
Map<String, Object> out = new HashMap<>();
out.put("myTheme", null);
out.put("schTheme", null);
out.put("avatarMy", null);
out.put("avatarSch", null);
out.put("logoUrl", null);
// 个人段(纯本地查询)
try {
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(user.getId());
if (ua.isPresent()) {
out.put("myTheme", validThemeState(ua.get().getThemeState()));
out.put("avatarMy", validAvatarStyle(ua.get().getAvatarStyle()));
}
} catch (Exception e) {
// 降级:个人段 null,不影响学校段
}
// 学校段(schId 解析含 identity 跨仓调用;失败整体降级 null)
try {
Integer schId = schSettingsService.resolveThemeSchId(user, null);
if (schId != null) {
SchSettings sch = schSettingsService.findBySchIdRaw(schId).orElse(null);
if (sch != null) {
out.put("schTheme", validThemeState(sch.getThemeName()));
out.put("avatarSch", validAvatarStyle(sch.getAvatarStyle()));
}
out.put("logoUrl", resolveLogoUrl(schId));
}
} catch (Exception e) {
// 降级:学校段 null(含 logo),功能不受影响
}
return out;
}
/**
* 当前用户生效外观(oapi 代理轮 2026-10-08,{@code /oapi/appearance} 出参真源;2026-10-09 JSON 化;
* 同日定稿摘除 logoText——子应用 header 定稿只出 logo 图,文字仅门户消费且走 /api/schLogoText 自有链,
* init 侧对缺失字段按 null 语义自动清 yh-logo-text 本地键):
* {@code {theme, avatar, logoUrl}}——theme 为【JSON 对象】(个人自选 → 学校默认 → 平台默认(schId=0)
* 级联,层级知识留在 UserHome 内部,对外不拆 my/sch);avatar 为生效样式;
* logoUrl 为本校 type=1 → 平台 type=0 择优。
*
* <p>schId 解析走本地 user_current_role(oapi 入参只有 userId,不引 identity 跨仓):roleId 当前角色学校,
* 无角色/跨校态回落 0(平台层)。与 /api/appearance 的 resolveThemeSchId(超管强制 0)仅在「超管临时切入
* 某校角色」场景不同——该场景按当前角色学校取默认反而更贴近其当下观感,属登记偏差而非回归。</p>
*/
@Override
public Map<String, Object> getEffectiveAppearance(Integer userId) {
Map<String, Object> out = new HashMap<>();
out.put("theme", null);
out.put("avatar", null);
out.put("logoUrl", null);
if (userId == null) {
return out;
}
Integer schId = userCurrentRoleService.findById(userId)
.map(UserCurrentRole::getSchId)
.filter(s -> s != null && s > 0)
.orElse(ISchSettingsService.PLATFORM_SCH_ID);
String myTheme = null;
String myAvatar = null;
try {
Optional<UserAppearance> ua = userAppearanceRepository.findByUserId(userId);
if (ua.isPresent()) {
myTheme = validThemeState(ua.get().getThemeState());
myAvatar = validAvatarStyle(ua.get().getAvatarStyle());
}
} catch (Exception e) {
// 个人段降级:myTheme/myAvatar 保持 null,不影响学校/平台段
}
String theme = myTheme;
String avatar = myAvatar;
try {
if (theme == null) {
theme = schSettingsService.readThemeWithFallback(schId).getValue();
}
if (avatar == null) {
avatar = schSettingsService.readAvatarWithFallback(schId).getValue();
}
out.put("logoUrl", resolveLogoUrl(schId));
} catch (Exception e) {
// 学校/平台段降级:theme/avatar/logoUrl 保持已得值或 null
}
// theme 出 JSON 对象(2026-10-09 契约);值已过 decodeThemeState 校验,解析失败按 null 降级
out.put("theme", theme == null ? null : ThemeCodec.toJsonNode(theme));
out.put("avatar", avatar);
return out;
}
@Override
public void saveMyTheme(Integer userId, String themeState) {
UserAppearance ua = loadOrCreate(userId);
if (themeState == null) {
// 跟随学校默认:置 NULL(列语义见实体注释)
ua.setThemeState(null);
} else {
// 写侧从严:只收 JSON、产规范化 JSON 串(紧凑入参抛 DException,2026-10-09 JSON 化契约)
ua.setThemeState(ThemeCodec.encodeThemeState(themeState));
}
ua.setUDate(new Date());
userAppearanceRepository.save(ua);
}
@Override
public void saveMyAvatarStyle(Integer userId, String avatarStyle) {
UserAppearance ua = loadOrCreate(userId);
if (avatarStyle == null) {
ua.setAvatarStyle(null);
} else {
String style = avatarStyle.trim();
if (!ThemeCodec.isAvatarStyle(style)) {
throw new DException("头像样式不合法: " + avatarStyle);
}
ua.setAvatarStyle(style);
}
ua.setUDate(new Date());
userAppearanceRepository.save(ua);
}
private UserAppearance loadOrCreate(Integer userId) {
return userAppearanceRepository.findByUserId(userId).orElseGet(() -> {
UserAppearance ua = new UserAppearance();
ua.setUserId(userId);
ua.setCDate(new Date());
return ua;
});
}
/** 主题档校验读(JSON 校验→规范化;旧紧凑四段兼容转 JSON;脏值 → null 视同未配置) */
private String validThemeState(String raw) {
return StringUtils.hasText(raw) ? ThemeCodec.decodeThemeState(raw) : null;
}
private String validAvatarStyle(String raw) {
return ThemeCodec.isAvatarStyle(raw) ? raw : null;
}
/** Logo 择优:本校(type=1)优先 → 系统(type=0) → null;URL 拼法对齐 UserSettingsController.getLogoList */
private String resolveLogoUrl(Integer schId) {
if (schId != null) {
String url = firstLogoUrl(logoRepository.findBySchIdAndType(schId, 1));
if (url != null) return url;
}
return firstLogoUrl(logoRepository.findBySchIdAndType(0, 0));
}
private String firstLogoUrl(List<Logo> list) {
if (list == null) return null;
for (Logo logo : list) {
String path = logo == null ? null : logo.getLogoUrl();
if (!StringUtils.hasText(path)) continue;
// 本地上传路径拼 FILE_WEB_PATH 绝对 URL;已是绝对地址的(预留跨仓存储)原样透传
if (path.startsWith("http://") || path.startsWith("https://")) return path;
return fileWebPath + path.replace("/upload", "");
}
return null;
}
}
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
@Override @Override
public List<BoaAppType> getUserApps(BoaUser user) { public List<BoaAppType> getUserApps(BoaUser user) {
return getUserApps(user, null);
}
@Override
public List<BoaAppType> getUserApps(BoaUser user, Integer schoolId) {
if (user == null || user.getId() == null) { if (user == null || user.getId() == null) {
return Collections.emptyList(); return Collections.emptyList();
} }
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量) // 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER); // 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);
} }
@Override @Override
public Optional<BoaApp> getAppById(BoaUser user, Integer appId) { public Optional<BoaApp> getAppById(BoaUser user, Integer appId) {
return getAppById(user, appId, null);
}
@Override
public Optional<BoaApp> getAppById(BoaUser user, Integer appId, Integer schoolId) {
if (appId == null) { if (appId == null) {
return Optional.empty(); return Optional.empty();
} }
return getUserApps(user).stream() return getUserApps(user, schoolId).stream()
.filter(t -> t.getApps() != null) .filter(t -> t.getApps() != null)
.flatMap(t -> t.getApps().stream()) .flatMap(t -> t.getApps().stream())
.filter(a -> appId.equals(a.getId())) .filter(a -> appId.equals(a.getId()))
@@ -1,8 +1,12 @@
package com.yunhai.userhome.service.impl; package com.yunhai.userhome.service.impl;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.SchSettings; import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.repository.SchSettingsRepository; import com.yunhai.userhome.repository.SchSettingsRepository;
import com.yunhai.userhome.service.ISchSettingsService; import com.yunhai.userhome.service.ISchSettingsService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.utils.ThemeCodec;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
@@ -18,11 +22,19 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
@Autowired @Autowired
private SchSettingsRepository schSettingsRepository; private SchSettingsRepository schSettingsRepository;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
@Override @Override
public void save(SchSettings schSettings) { public void save(SchSettings schSettings) {
schSettingsRepository.save(schSettings); schSettingsRepository.save(schSettings);
} }
@Override
public Optional<SchSettings> findBySchIdRaw(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId);
}
@Override @Override
public Optional<SchSettings> findBySchId(Integer schId) { public Optional<SchSettings> findBySchId(Integer schId) {
Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId); Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId);
@@ -41,4 +53,100 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
public java.util.List<SchSettings> findBySchIds(java.util.List<Integer> schId) { public java.util.List<SchSettings> findBySchIds(java.util.List<Integer> schId) {
return schSettingsRepository.getSchSettingList(schId); return schSettingsRepository.getSchSettingList(schId);
} }
@Override
public Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
}
if (user.getBureau() != null) {
return user.getBureau().getId();
}
return 0;
}
@Override
public Integer resolveThemeSchId(BoaUser user, Integer explicitSchId) {
if (user.getType() != null && user.getType() == 1) {
return explicitSchId == null ? 0 : explicitSchId;
}
return userCurrentRoleService.findById(user.getId())
.map(UserCurrentRole::getSchId)
.filter(schId -> schId != null && schId > 0)
.orElseGet(() -> resolveSchId(user));
}
@Override
public ISchSettingsService.LayerValue readThemeWithFallback(Integer schId) {
String themeState = decodeTheme(schId);
if (themeState != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(themeState, schId);
}
return new ISchSettingsService.LayerValue(decodeTheme(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
@Override
public ISchSettingsService.LayerValue readAvatarWithFallback(Integer schId) {
String avatarStyle = readAvatarAt(schId);
if (avatarStyle != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(avatarStyle, schId);
}
return new ISchSettingsService.LayerValue(readAvatarAt(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
@Override
public ISchSettingsService.LayerValue readLogoTextWithFallback(Integer schId) {
String text = readLogoTextAt(schId);
if (text != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(text, schId);
}
return new ISchSettingsService.LayerValue(readLogoTextAt(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
/** 原样读某层 LOGO 文字(查无行/空白 → null;trim 后存储口径) */
private String readLogoTextAt(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getLogoText)
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElse(null);
}
@Override
public ISchSettingsService.LayerValue readWallpaperWithFallback(Integer schId) {
String url = readWallpaperAt(schId);
if (url != null || schId == null || schId == ISchSettingsService.PLATFORM_SCH_ID) {
return new ISchSettingsService.LayerValue(url, schId);
}
return new ISchSettingsService.LayerValue(readWallpaperAt(ISchSettingsService.PLATFORM_SCH_ID),
ISchSettingsService.PLATFORM_SCH_ID);
}
/** 原样读某层默认壁纸(查无行/空白 → null;trim 后存储口径,URL 为管理员从池中选定的原值) */
private String readWallpaperAt(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getWallpaperUrl)
.map(String::trim)
.filter(s -> !s.isEmpty())
.orElse(null);
}
/** 原样读某层主题档并解码(查无行/旧格式/脏值 → null) */
private String decodeTheme(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getThemeName)
.map(ThemeCodec::decodeThemeState)
.orElse(null);
}
/** 原样读某层头像样式(查无行/非法值 → null) */
private String readAvatarAt(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId)
.map(SchSettings::getAvatarStyle)
.filter(ThemeCodec::isAvatarStyle)
.orElse(null);
}
} }
@@ -4,8 +4,10 @@ import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType; import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaUser; import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.UsedApp; import com.yunhai.userhome.entity.UsedApp;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.model.SetType; import com.yunhai.userhome.model.SetType;
import com.yunhai.userhome.repository.UsedAppRepository; import com.yunhai.userhome.repository.UsedAppRepository;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService; import com.yunhai.userhome.service.AppRemoteService;
import com.yunhai.userhome.service.UsedAppService; import com.yunhai.userhome.service.UsedAppService;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -34,6 +36,9 @@ public class UsedAppServiceImpl implements UsedAppService {
@Autowired @Autowired
private AppRemoteService appRemoteService; private AppRemoteService appRemoteService;
@Autowired
private AppIconKeyService appIconKeyService;
@Override @Override
public void save(UsedApp usedApp) { public void save(UsedApp usedApp) {
Optional<UsedApp> model = usedAppRepository Optional<UsedApp> model = usedAppRepository
@@ -138,6 +143,7 @@ public class UsedAppServiceImpl implements UsedAppService {
m.put("name", app.getName()); m.put("name", app.getName());
m.put("icon", app.getIcon()); m.put("icon", app.getIcon());
m.put("url", app.getWebAddr()); m.put("url", app.getWebAddr());
putIconKeys(m, app.getId());
list.add(m); list.add(m);
} }
} catch (NumberFormatException ignore) { } catch (NumberFormatException ignore) {
@@ -147,6 +153,13 @@ public class UsedAppServiceImpl implements UsedAppService {
return list; return list;
} }
/** 精简映射上补矢量图标键(iconKey/iconColor,AppIcon 通道;未配置时值为 null,前端按空走原回退链)。 */
private void putIconKeys(Map<String, Object> m, Integer appId) {
AppIconEntryVo entry = appIconKeyService.byId(appId);
m.put("iconKey", entry == null ? null : entry.getIconKey());
m.put("iconColor", entry == null ? null : entry.getIconColor());
}
/** BoaAppType 分组平铺为 BoaApp 列表。 */ /** BoaAppType 分组平铺为 BoaApp 列表。 */
private List<BoaApp> flattenApps(List<BoaAppType> types) { private List<BoaApp> flattenApps(List<BoaAppType> types) {
List<BoaApp> apps = new ArrayList<>(); List<BoaApp> apps = new ArrayList<>();
@@ -53,6 +53,10 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
@Value("${FILE_PATH:./upload/}") @Value("${FILE_PATH:./upload/}")
private String FILE_PATH; private String FILE_PATH;
/** 与 UserSettingsController 同源:展示形态 = 该前缀 + 去掉 /upload 的池内原始路径 */
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Override @Override
public List<Wallpaper> findWallList(Integer userId) { public List<Wallpaper> findWallList(Integer userId) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId); Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
@@ -136,8 +140,11 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
if (!(superAdmin || ownPersonal || schAdminOwn)) { if (!(superAdmin || ownPersonal || schAdminOwn)) {
throw new BizException(ErrorCode.FORBIDDEN); throw new BizException(ErrorCode.FORBIDDEN);
} }
// 使用中清理:被删壁纸被用户设置引用时置空,避免裂图 // 使用中清理:被删壁纸被用户设置引用时置空,避免裂图。
userSettingsRepository.clearWallpaperUrl(wallpaper.getWallpaperUrl()); // user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),与 wall 表原始形态不同,两种都清
String rawUrl = wallpaper.getWallpaperUrl();
String displayUrl = (FILE_WEB_PATH == null ? "" : FILE_WEB_PATH) + rawUrl.replace("/upload", "");
userSettingsRepository.clearWallpaperUrl(java.util.Arrays.asList(rawUrl, displayUrl));
// 清理物理文件(非本地上传的跨仓地址静默跳过) // 清理物理文件(非本地上传的跨仓地址静默跳过)
FileUtil.deleteFile(FILE_PATH, wallpaper.getWallpaperUrl()); FileUtil.deleteFile(FILE_PATH, wallpaper.getWallpaperUrl());
wallpaperRepository.delete(wallpaper); wallpaperRepository.delete(wallpaper);
@@ -0,0 +1,418 @@
package com.yunhai.userhome.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.userhome.entity.WidgetCatalog;
import com.yunhai.userhome.entity.WidgetCatalogSchool;
import com.yunhai.userhome.entity.WidgetSchoolSetting;
import com.yunhai.userhome.repository.WidgetCatalogRepository;
import com.yunhai.userhome.repository.WidgetCatalogSchoolRepository;
import com.yunhai.userhome.repository.WidgetSchoolSettingRepository;
import com.yunhai.userhome.service.IWidgetCatalogService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* 小组件候选池服务(对齐 IUserSettingsService 分层:Controller 只做鉴权与出参组装,规则在此)。
*
* <p>生效值合成口径:生效值 = COALESCE(校级覆盖行, 全局值);显示名 = 校级别名 &gt; 角色专属命名 &gt; 组件名称;
* 可见性 = 生效启用 AND 可见范围命中 AND 可见角色命中。</p>
*/
@Service
public class WidgetCatalogServiceImpl implements IWidgetCatalogService {
private static final Logger log = LoggerFactory.getLogger(WidgetCatalogServiceImpl.class);
private static final int COMP_TYPE_BUILTIN = 1;
private static final int COMP_TYPE_FRAGMENT = 2;
private static final int ENABLED = 1;
/** 下发范围:全部学校 */
private static final int SCOPE_ALL = 0;
private static final ObjectMapper JSON_MAPPER = new ObjectMapper();
@Autowired
private WidgetCatalogRepository widgetCatalogRepository;
@Autowired
private WidgetCatalogSchoolRepository widgetCatalogSchoolRepository;
@Autowired
private WidgetSchoolSettingRepository widgetSchoolSettingRepository;
@Override
public List<WidgetCatalog> findEnabledForSchool(Integer schId, Integer roleId) {
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(schId);
List<WidgetCatalog> result = new ArrayList<>();
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(schId)) {
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
if (effectiveEnabled(catalog, override) != ENABLED) continue;
if (!roleHit(catalog, override, roleId)) continue;
result.add(effectiveCopy(catalog, override, schId, roleId));
}
result.sort(Comparator.comparing(WidgetCatalog::getSort, Comparator.nullsFirst(Integer::compareTo))
.thenComparing(WidgetCatalog::getId, Comparator.nullsFirst(Integer::compareTo)));
return result;
}
@Override
public Optional<WidgetCatalog> findByCode(String code) {
if (code == null || code.trim().isEmpty()) {
return Optional.empty();
}
return widgetCatalogRepository.findByCode(code);
}
@Override
public boolean visibleToSchool(WidgetCatalog catalog, Integer schId) {
int scopeType = catalog.getScopeType() == null ? SCOPE_ALL : catalog.getScopeType();
if (scopeType == SCOPE_ALL) {
return true;
}
boolean pointed = schId != null
&& widgetCatalogSchoolRepository.existsByWidgetIdAndSchId(catalog.getId(), schId);
// 1 = 白名单(点名才可见);2 = 黑名单(点名不可见)
return scopeType == 1 ? pointed : !pointed;
}
@Override
public List<WidgetCatalog> findAllForAdmin() {
List<WidgetCatalog> list = widgetCatalogRepository.findAllByOrderBySortAscIdAsc();
fillScopeSchIds(list);
return list;
}
@Override
public List<WidgetCatalog> findEffectiveForAdmin(Integer viewerSchId, boolean superAdmin) {
if (superAdmin || viewerSchId == null) {
return findAllForAdmin();
}
Map<Integer, WidgetSchoolSetting> overrideMap = loadOverrideMap(viewerSchId);
List<WidgetCatalog> result = new ArrayList<>();
for (WidgetCatalog catalog : widgetCatalogRepository.findVisibleForSchool(viewerSchId)) {
WidgetSchoolSetting override = overrideMap.get(catalog.getId());
WidgetCatalog item = new WidgetCatalog();
item.setId(catalog.getId());
item.setCode(catalog.getCode());
item.setName(effectiveName(catalog, override, null));
item.setOriginalName(catalog.getName());
item.setAlias(override == null ? null : override.getAlias());
item.setCovered(override != null);
item.setCompType(catalog.getCompType());
item.setContent(catalog.getContent());
item.setDataCode(catalog.getDataCode());
item.setScopeType(catalog.getScopeType());
item.setRoleTypes(effectiveRoleTypes(catalog, override));
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);
}
fillScopeSchIds(result);
return result;
}
@Override
@Transactional
public void save(WidgetCatalog model) {
if (model.getCode() == null || model.getCode().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件键 code");
}
if (model.getName() == null || model.getName().trim().isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "缺少名称");
}
if (model.getCompType() == null
|| (model.getCompType() != COMP_TYPE_BUILTIN && model.getCompType() != COMP_TYPE_FRAGMENT)) {
throw new BizException(ErrorCode.CUSTOM, "类型只能是内置(1)或自定义片段(2)");
}
Optional<WidgetCatalog> exist = widgetCatalogRepository.findByCode(model.getCode());
if (exist.isPresent() && !exist.get().getId().equals(model.getId())) {
throw new BizException(ErrorCode.CUSTOM, "组件键已存在:" + model.getCode());
}
int scopeType = model.getScopeType() == null ? SCOPE_ALL : model.getScopeType();
if (scopeType != SCOPE_ALL && scopeType != 1 && scopeType != 2) {
throw new BizException(ErrorCode.CUSTOM, "可见范围只能是 全部学校(0) / 指定可见(1) / 指定不可见(2)");
}
model.setScopeType(scopeType);
if (scopeType != SCOPE_ALL && (model.getScopeSchIds() == null || model.getScopeSchIds().isEmpty())) {
throw new BizException(ErrorCode.CUSTOM, "请选择学校后再保存可见范围");
}
if (model.getSort() == null) model.setSort(0);
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);
}
@Override
@Transactional
public void deleteById(Integer id) {
if (id == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
}
widgetCatalogSchoolRepository.deleteByWidgetId(id);
widgetSchoolSettingRepository.deleteByWidgetId(id);
widgetCatalogRepository.deleteById(id);
}
@Override
public Optional<WidgetSchoolSetting> findSchoolSetting(Integer widgetId, Integer schId) {
if (widgetId == null || schId == null) {
return Optional.empty();
}
return widgetSchoolSettingRepository.findByWidgetIdAndSchId(widgetId, schId);
}
@Override
@Transactional
public void saveSchoolSetting(WidgetSchoolSetting model, Integer currentSchId) {
if (model == null || model.getWidgetId() == null) {
throw new BizException(ErrorCode.CUSTOM, "缺少组件 id");
}
if (currentSchId == null) {
throw new BizException(ErrorCode.CUSTOM, "当前登录身份没有学校,无法保存本校设置");
}
// 防越权:schId 一律取当前登录校,不采信前端传参
model.setSchId(currentSchId);
if (!widgetCatalogRepository.findById(model.getWidgetId()).isPresent()) {
throw new BizException(ErrorCode.CUSTOM, "组件不存在:" + model.getWidgetId());
}
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.getLinkAppCode() == null && model.getLinkPath() == null;
if (clearing) {
if (exist != null) widgetSchoolSettingRepository.delete(exist);
return;
}
if (exist != null) model.setId(exist.getId());
model.setUpdateTime(new Date());
widgetSchoolSettingRepository.save(model);
}
@Override
public String displayName(WidgetCatalog catalog, Integer schId, Integer roleId) {
WidgetSchoolSetting override = null;
if (schId != null && catalog.getId() != null) {
override = widgetSchoolSettingRepository
.findByWidgetIdAndSchId(catalog.getId(), schId).orElse(null);
}
return effectiveName(catalog, override, roleId);
}
// ==================== 私有方法 ====================
/** 显示名三级链:校级别名 > 角色专属命名 > 组件名称 */
private String effectiveName(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
if (override != null && StringUtils.hasText(override.getAlias())) {
return override.getAlias();
}
String aliasByRole = roleAliasOf(catalog.getRoleAlias(), roleId);
if (StringUtils.hasText(aliasByRole)) {
return aliasByRole;
}
return catalog.getName();
}
/** 角色专属命名读取:JSON 文本里取当前角色那一项;解析失败按「无角色命名」降级,不抛 500 */
private String roleAliasOf(String roleAliasJson, Integer roleId) {
if (!StringUtils.hasText(roleAliasJson) || roleId == null) {
return null;
}
try {
JsonNode value = JSON_MAPPER.readTree(roleAliasJson).get(String.valueOf(roleId));
return value == null || value.isNull() ? null : value.asText();
} catch (Exception e) {
log.warn("[widget] role_alias 解析失败,按无角色命名降级:{}", roleAliasJson);
return null;
}
}
private boolean roleHit(WidgetCatalog catalog, WidgetSchoolSetting override, Integer roleId) {
String roleTypes = effectiveRoleTypes(catalog, override);
if (!StringUtils.hasText(roleTypes)) {
return true;
}
if (roleId == null) {
return false;
}
for (String token : roleTypes.split(",")) {
if (roleId.equals(parseInt(token))) {
return true;
}
}
return false;
}
private String effectiveRoleTypes(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && StringUtils.hasText(override.getRoleTypes())) {
return override.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) {
if (override != null && override.getEnabled() != null) {
return override.getEnabled();
}
return catalog.getEnabled() == null ? ENABLED : catalog.getEnabled();
}
private int effectiveSort(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getSortNo() != null) {
return override.getSortNo();
}
return catalog.getSort() == null ? 0 : catalog.getSort();
}
private Date resolveUpdateTime(WidgetCatalog catalog, WidgetSchoolSetting override) {
if (override != null && override.getUpdateTime() != null) {
return override.getUpdateTime();
}
return catalog.getUpdateTime();
}
/** 下发视图:复制出独立对象并写入生效值,避免改动 JPA 托管实体(脏检查) */
private WidgetCatalog effectiveCopy(WidgetCatalog catalog, WidgetSchoolSetting override,
Integer schId, Integer roleId) {
WidgetCatalog item = new WidgetCatalog();
item.setId(catalog.getId());
item.setCode(catalog.getCode());
item.setName(effectiveName(catalog, override, roleId));
item.setCompType(catalog.getCompType());
item.setContent(catalog.getContent());
item.setDataCode(catalog.getDataCode());
item.setScopeType(catalog.getScopeType());
item.setRoleTypes(effectiveRoleTypes(catalog, override));
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;
}
private Map<Integer, WidgetSchoolSetting> loadOverrideMap(Integer schId) {
Map<Integer, WidgetSchoolSetting> map = new HashMap<>();
if (schId == null) {
return map;
}
for (WidgetSchoolSetting setting : widgetSchoolSettingRepository.findBySchId(schId)) {
map.put(setting.getWidgetId(), setting);
}
return map;
}
private void fillScopeSchIds(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> pointMap = loadPointSchoolMap(catalogs);
for (WidgetCatalog catalog : catalogs) {
List<Integer> ids = pointMap.get(catalog.getId());
catalog.setScopeSchIds(ids == null ? new ArrayList<>() : ids);
}
}
private Map<Integer, List<Integer>> loadPointSchoolMap(Collection<WidgetCatalog> catalogs) {
Map<Integer, List<Integer>> map = new HashMap<>();
List<Integer> widgetIds = new ArrayList<>();
for (WidgetCatalog catalog : catalogs) {
if (catalog.getId() != null) widgetIds.add(catalog.getId());
}
if (widgetIds.isEmpty()) {
return map;
}
for (WidgetCatalogSchool row : widgetCatalogSchoolRepository.findByWidgetIdIn(widgetIds)) {
map.computeIfAbsent(row.getWidgetId(), key -> new ArrayList<>()).add(row.getSchId());
}
return map;
}
/** 整体重写点名表:scope_type=0 或未选校时清空 */
private void rewritePointSchools(WidgetCatalog model) {
widgetCatalogSchoolRepository.deleteByWidgetId(model.getId());
if (model.getScopeType() == null || model.getScopeType() == SCOPE_ALL) {
return;
}
List<Integer> schIds = model.getScopeSchIds();
if (schIds == null || schIds.isEmpty()) {
return;
}
List<WidgetCatalogSchool> rows = new ArrayList<>();
for (Integer schId : schIds) {
if (schId == null) continue;
WidgetCatalogSchool row = new WidgetCatalogSchool();
row.setWidgetId(model.getId());
row.setSchId(schId);
rows.add(row);
}
if (!rows.isEmpty()) {
widgetCatalogSchoolRepository.saveAll(rows);
}
}
private Integer parseInt(String text) {
if (text == null) {
return null;
}
try {
return Integer.valueOf(text.trim());
} catch (NumberFormatException e) {
return null;
}
}
}
@@ -0,0 +1,110 @@
package com.yunhai.userhome.utils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.utils.exception.DException;
import org.springframework.util.StringUtils;
/**
* 主题档/头像样式的编解码与校验(原 SchSettingsController 私有实现下沉,2026-10-08 外观偏好轮;
* 2026-10-09 存储与传输全面 JSON 化)。
* <p>存储形态:{@code {"theme":"teal","sideDeep":false,"gradOn":false,"sideFollow":false}}
* (固定字段序的规范化 JSON 串;sch_settings.theme_name 与 user_appearance.theme_state 同形态)。
* 历史紧凑编码 {@code theme|sideDeep01|gradOn01|sideFollow01} 仅在【读侧】兼容——解码时自动转 JSON,
* 写侧一律产出 JSON(存量行靠迁移 UPDATE 或读时懒迁移消化)。
* 读侧宽松(脏值/不可解析一律 null 视同未配置),写侧从严(非法抛 DException)。</p>
*/
public final class ThemeCodec {
/** 合法档位(与通道 THEME_NAMES 一致;防旧库脏值意外生效) */
public static final String[] THEME_KEYS = {"blue", "ocean", "ink", "violet", "teal"};
/** 合法头像样式(与前端 avatar.ts 的 AvatarStyle 一致;写入侧从严,对齐 encodeThemeState 做法) */
public static final String[] AVATAR_STYLES = {"text", "silhouette"};
private static final ObjectMapper MAPPER = new ObjectMapper();
private ThemeCodec() {
}
/** 解析并校验 ThemeState JSON(四字段齐全、档名合法),合法返回【规范化 JSON 串】;非法 null */
private static String normalizeJson(String themeStateJson) {
try {
JsonNode n = MAPPER.readTree(themeStateJson);
if (n == null || !n.isObject() || !n.hasNonNull("theme") || !n.has("sideDeep")
|| !n.has("gradOn") || !n.has("sideFollow")) {
return null;
}
String theme = n.get("theme").asText();
if (!isThemeKey(theme)) {
return null;
}
return "{\"theme\":\"" + theme + "\",\"sideDeep\":" + n.get("sideDeep").asBoolean()
+ ",\"gradOn\":" + n.get("gradOn").asBoolean()
+ ",\"sideFollow\":" + n.get("sideFollow").asBoolean() + "}";
} catch (Exception e) {
return null;
}
}
/** 旧紧凑编码 theme|01|01|01 → JSON;非本形态返回 null */
private static String fromCompact(String compact) {
String[] p = compact.trim().split("\\|");
if (p.length != 4 || !isThemeKey(p[0])) {
return null;
}
for (int i = 1; i < 4; i++) {
if (p[i].length() != 1 || (p[i].charAt(0) != '0' && p[i].charAt(0) != '1')) {
return null;
}
}
return "{\"theme\":\"" + p[0] + "\",\"sideDeep\":" + ("1".equals(p[1]))
+ ",\"gradOn\":" + ("1".equals(p[2]))
+ ",\"sideFollow\":" + ("1".equals(p[3])) + "}";
}
/**
* 读侧统一入口:JSON 校验 → 规范化 JSON;旧紧凑四段 → 转 JSON;其余(旧档名/脏值)null(视同未配置)。
* 返回值形态:ThemeState JSON 串(与 localStorage yh-theme 键形态一致)。
*/
public static String decodeThemeState(String raw) {
if (!StringUtils.hasText(raw)) return null;
String s = raw.trim();
return s.startsWith("{") ? normalizeJson(s) : fromCompact(s);
}
/** ThemeState JSON 校验(四字段齐全、档名合法)+ 规范化(固定字段序);非法抛业务异常(写入侧从严) */
public static String encodeThemeState(String themeStateJson) {
if (!StringUtils.hasText(themeStateJson)) throw new DException("主题档不能为空");
String normalized = normalizeJson(themeStateJson.trim());
if (normalized == null) {
throw new DException("主题档格式不合法");
}
return normalized;
}
/** ThemeState JSON 串 → 节点(oapi 出参对象形态用);入串须先过 decodeThemeState 校验 */
public static JsonNode toJsonNode(String themeStateJson) {
try {
return MAPPER.readTree(themeStateJson);
} catch (Exception e) {
return null;
}
}
public static boolean isThemeKey(String theme) {
if (theme == null) return false;
for (String k : THEME_KEYS) {
if (k.equals(theme)) return true;
}
return false;
}
public static boolean isAvatarStyle(String style) {
if (style == null) return false;
for (String k : AVATAR_STYLES) {
if (k.equals(style)) return true;
}
return false;
}
}
+27 -10
View File
@@ -64,10 +64,30 @@ yh:
app-key: 100001 app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86 app-secret: ec4e860206374c80938719fff8e14a86
open: open:
# yh-openapi 服务目录:userbase/userclass/usermsg/flowcenter 枚举兜底 = base-host + 默认 ctx; # yh-openapi 服务目录:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx;
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删 # 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com base-host: http://base.yunhaixingtong.com
base-path: /userbase base-path: /userbase
call:
# YschCall 直调目标目录(寻址优先级高于 yh.open.services 与 PlatformService 枚举兜底)。
# acasyllabu:课程表 oapi(小组件 syllabus 取数)。base-path 即目标 ctx,
# 客户端签名按该 ctx 剥离(待签 target = /oapi/timetable/Get?...,不含 /aca-syllabu)。
# ⚠️ 真实 ctx = /aca-syllabu(带横线,20261013 生产实测 401 定位);手册写的 /acasyllabu 有误。
# 本地联调:AcaSyllabu 在 8109 且无 ctx ⇒ 覆盖 base-host: http://localhost:8109、base-path 留空。
# base-host 指 open-gateway(开放网关,容器网地址):20261015 切门收口,oapi 统一经网关
# 验签/转发/台账;签名口径不变(网关 canonical 与 starter 逐字节一致)。base-path 即
# 网关 target 段(= 目标 ctx)。
targets:
acasyllabu:
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,数据源见 # 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
@@ -90,21 +110,18 @@ userhome:
# 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底); # 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底);
# 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg # 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/ws/msg
usermsg-ws-url: ws://localhost:8080/ws/msg usermsg-ws-url: ws://localhost:8080/ws/msg
# flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**)。空 = 走服务目录 flow-center
# (PlatformService 枚举兜底);不可达时直批降级提示,不阻塞消息卡片
flow-center-base-url: http://localhost:8085
# appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案) # appKey → 应用显示名(消息行应用列/铃铛标签补全;未命中回退 msgType 文案)
app-names: app-names:
100009: 通知公告 100009: 通知公告
100617: 通知公告 100617: 通知公告
100019: 流程中心 100019: 流程中心
100001: 用户主页 100001: 用户主页
# bizType → 应用 front 基址:jumpPath 是裸相对路径(如 /task/123)且落地页是 hash 路由, # 跳转基址唯一来源 = 应用注册表(UserBase /oapi/app/by-code|by-key 取 pc_url,§3.2 归一化后
# 代理拼 jumpUrl = 基址 + "/#" + jumpPath。生产(Nacos userhome.yaml 覆盖)同主域相对段: # 拼 "#" + jumpPath);已是绝对 URL 的存量消息直接透传。手工基址表 jump-bases 已退役(C2),
# jump-bases: { flow: /flow-center, flow.task: /flow-center, flow.result: /flow-center, flow.urge: /flow-center } # 生产 Nacos userhome.yaml 若残留 jump-bases 段应一并清理(Spring 静默忽略,留着误导)。
jump-bases:
flow: http://localhost:8088
flow.task: http://localhost:8088
flow.result: http://localhost:8088
flow.urge: http://localhost:8088
notice: http://localhost:8077
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。 # [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患; # Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
@@ -0,0 +1,6 @@
-- 学校默认头像样式(默认头像轮 2026-09-30):sch_settings 新增 avatar_style 列。
-- dev 环境由 JPA ddl-auto=update 自动加列;测试/生产等 ddl-auto 关闭的环境手工执行本文件。
ALTER TABLE sch_settings ADD COLUMN avatar_style VARCHAR(16) NULL COMMENT '学校默认头像样式 text|silhouette';
-- 回滚:
-- ALTER TABLE sch_settings DROP COLUMN avatar_style;
+38
View File
@@ -0,0 +1,38 @@
-- ============================================================
-- 小组件候选池 widget_catalog(UserHome小组件内置化落地方案-20261009 §3.1)
-- 候选池真源由旧 bsch 开放接口换成本地表;内容自持,全表没有 url / html 地址字段。
-- comp_type=1 → 内容在 front 工程(内置 Vue 组件),本表只登记键;
-- comp_type=2 → 内容在 content(管理端贴/传,srcdoc 渲染)。
-- dev 由 JPA ddl-auto=update 依实体自动建表;测试/生产等关闭 ddl-auto 的环境手工执行本文件。
-- ============================================================
CREATE TABLE IF NOT EXISTS `widget_catalog` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`code` VARCHAR(32) NOT NULL COMMENT '组件键:内置=前端注册表键,自定义=自定义标识',
`name` VARCHAR(64) NOT NULL COMMENT '展示名(候选池文案 / 面板标题)',
`comp_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1=front 内置组件 2=自定义 HTML 片段',
`content` MEDIUMTEXT NULL COMMENT 'comp_type=2 的 HTML 源码;内置类型恒空',
`data_code` VARCHAR(32) NULL COMMENT '片段取数用的数据键(须在 WidgetDataService 白名单内)',
`sch_id` INT NULL COMMENT '下发范围:NULL=全校可见(本期全校一致,字段与过滤钩子先就位)',
`sort` INT NOT NULL DEFAULT 0 COMMENT '候选池排序',
`enabled` TINYINT NOT NULL DEFAULT 1 COMMENT '1=进候选池 0=下线',
`update_time` DATETIME NULL COMMENT '内容最后修改时间(管理端展示)',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_catalog_code` (`code`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件候选池(内容自持,无外部 html 托管依赖)';
-- 初始化三个存量组件(判定规则 §9:全部内置,content 留空)
-- code 上有唯一键,用 INSERT IGNORE 保证脚本可重复执行
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('evaluate', '听评课', 1, NULL, NULL, NULL, 1, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('syllabus', '课程表', 1, NULL, NULL, NULL, 2, 1, NOW());
INSERT IGNORE INTO `widget_catalog` (`code`, `name`, `comp_type`, `content`, `data_code`, `sch_id`, `sort`, `enabled`, `update_time`)
VALUES ('weekplan', '一周安排', 1, NULL, NULL, NULL, 3, 1, NOW());
-- 回滚:
-- DROP TABLE IF EXISTS `widget_catalog`;
@@ -0,0 +1,51 @@
-- ============================================================
-- 小组件管理改版 v2(doc/小组件管理改版-执行方案.md §3)
-- dev 由 JPA ddl-auto=update 依实体自动加列/建表;本文件供关闭 ddl-auto 的测试/生产环境手工执行一次。
-- 幂等性:CREATE TABLE 用 IF NOT EXISTS;ALTER ADD COLUMN 在 MySQL 8 无 IF NOT EXISTS,
-- 重复执行会报 1060 Duplicate column name,属预期,按需手动跳过即可。
-- ============================================================
-- ---------- 1. widget_catalog 加 3 列 ----------
ALTER TABLE `widget_catalog`
ADD COLUMN `scope_type` TINYINT NOT NULL DEFAULT 0
COMMENT '下发范围:0=全部学校 1=白名单(仅点名的学校可见) 2=黑名单(点名的学校不可见)',
ADD COLUMN `role_types` VARCHAR(16) NULL
COMMENT '可见角色:逗号分隔 RoleTypeEnum(1教师/2校领导/3学生);NULL/空=全角色',
ADD COLUMN `role_alias` VARCHAR(255) NULL
COMMENT '角色专属命名(JSON 文本):{"2":"全校课表"},键=RoleTypeEnum;读侧按当前角色取值';
-- sch_id 旧语义(NULL=全校 / 单校等值)由 scope_type 取代,标记弃用:列保留以兼容存量数据,读侧不再使用。
-- ---------- 2. 新增点名表(配合 scope_type 白/黑名单) ----------
CREATE TABLE IF NOT EXISTS `widget_catalog_school` (
`id` INT NOT NULL AUTO_INCREMENT,
`widget_id` INT NOT NULL COMMENT 'widget_catalog.id',
`sch_id` INT NOT NULL COMMENT '被点名的学校',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_school` (`widget_id`, `sch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件可见范围点名表(配合 scope_type 白/黑名单)';
-- ---------- 3. 新增校级覆盖层 ----------
CREATE TABLE IF NOT EXISTS `widget_school_setting` (
`id` INT NOT NULL AUTO_INCREMENT,
`widget_id` INT NOT NULL,
`sch_id` INT NOT NULL,
`enabled` TINYINT NULL COMMENT 'NULL=沿用全局',
`role_types` VARCHAR(16) NULL COMMENT 'NULL=沿用全局',
`sort_no` INT NULL COMMENT 'NULL=沿用全局',
`alias` VARCHAR(64) NULL COMMENT '本校别名,NULL=沿用全局 name',
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_widget_sch` (`widget_id`, `sch_id`)
) ENGINE = InnoDB
DEFAULT CHARSET = utf8mb4
COMMENT = '小组件校级覆盖(一键清除=删行)';
-- 存量 3 条内置目录行的 scope_type / role_types / role_alias 保持默认(0 / NULL / NULL)即「全部学校 + 全角色」。
-- 回滚:
-- ALTER TABLE `widget_catalog` DROP COLUMN `scope_type`, DROP COLUMN `role_types`, DROP COLUMN `role_alias`;
-- DROP TABLE IF EXISTS `widget_catalog_school`;
-- DROP TABLE IF EXISTS `widget_school_setting`;
@@ -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,311 @@
package com.yunhai.userhome.service;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.Logo;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserAppearance;
import com.yunhai.userhome.repository.LogoRepository;
import com.yunhai.userhome.repository.UserAppearanceRepository;
import com.yunhai.userhome.service.impl.AppearanceServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Arrays;
import java.util.Collections;
import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* AppearanceService 单测(外观偏好服务端化 2026-10-08):
* 冻结 GET /api/appearance 聚合契约(myTheme/schTheme 紧凑编码、脏值与依赖故障降级 null、永不抛)、
* upsert 置 NULL 语义(null=跟随学校默认)与 Logo 择优口径(本校 type=1 → 系统 type=0 → null)。
*/
@DisplayName("AppearanceService - 聚合降级与 upsert 置 NULL 语义")
class AppearanceServiceTest {
private UserAppearanceRepository userAppearanceRepository;
private ISchSettingsService schSettingsService;
private LogoRepository logoRepository;
private AppearanceServiceImpl service;
private BoaUser user;
@BeforeEach
void setUp() {
userAppearanceRepository = mock(UserAppearanceRepository.class);
schSettingsService = mock(ISchSettingsService.class);
logoRepository = mock(LogoRepository.class);
service = new AppearanceServiceImpl();
ReflectionTestUtils.setField(service, "userAppearanceRepository", userAppearanceRepository);
ReflectionTestUtils.setField(service, "schSettingsService", schSettingsService);
ReflectionTestUtils.setField(service, "logoRepository", logoRepository);
ReflectionTestUtils.setField(service, "fileWebPath", "http://base.yunhaixingtong.com/userhome/upload/");
user = new BoaUser();
user.setId(10133);
user.setType(0);
when(schSettingsService.resolveThemeSchId(any(), any())).thenReturn(3);
}
private static SchSettings schSettings(String themeName, String avatarStyle) {
SchSettings s = new SchSettings();
s.setThemeName(themeName);
s.setAvatarStyle(avatarStyle);
return s;
}
private static Logo logo(String url) {
Logo l = new Logo();
l.setLogoUrl(url);
return l;
}
@Test
@DisplayName("聚合:个人段+学校段+Logo 全存在 → 五字段齐全(紧凑旧值读侧转 JSON、Logo 拼 FILE_WEB_PATH 绝对 URL)")
void aggregateFull() {
UserAppearance ua = new UserAppearance();
ua.setThemeState("ocean|1|0|1"); // 旧紧凑值(读侧兼容自动转 JSON)
ua.setAvatarStyle("silhouette");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(
schSettings("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", "text")));
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png")));
when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png")));
Map<String, Object> out = service.getAppearance(user);
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":true}", out.get("myTheme"));
assertEquals("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", out.get("schTheme"));
assertEquals("silhouette", out.get("avatarMy"));
assertEquals("text", out.get("avatarSch"));
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", out.get("logoUrl"));
}
@Test
@DisplayName("聚合:无任何数据 → 五字段全 null 且不抛(200 出参口径)")
void aggregateEmpty() {
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty());
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getAppearance(user);
for (String k : new String[]{"myTheme", "schTheme", "avatarMy", "avatarSch", "logoUrl"}) {
assertTrue(out.containsKey(k));
assertNull(out.get(k));
}
}
@Test
@DisplayName("聚合:脏值降级(theme_state 旧格式/头像样式非法 → 对应字段 null,其余字段不受影响)")
void aggregateDirtyValues() {
UserAppearance ua = new UserAppearance();
ua.setThemeState("default-theme");
ua.setAvatarStyle("circle");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.of(schSettings("teal|0|1|1", "text")));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getAppearance(user);
assertNull(out.get("myTheme"));
assertNull(out.get("avatarMy"));
assertEquals("{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}", out.get("schTheme"),
"学校层紧凑旧值读侧转 JSON");
assertEquals("text", out.get("avatarSch"));
}
@Test
@DisplayName("聚合:identity 跨仓故障 → 学校段(含 Logo)整体 null,个人段不受影响且不抛")
void aggregateIdentityFailure() {
UserAppearance ua = new UserAppearance();
ua.setThemeState("ocean|1|0|1");
ua.setAvatarStyle("text");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(schSettingsService.resolveThemeSchId(any(), any())).thenThrow(new RuntimeException("userbase down"));
Map<String, Object> out = service.getAppearance(user);
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":true}", out.get("myTheme"),
"存量紧凑值读侧兼容转 JSON");
assertEquals("text", out.get("avatarMy"));
assertNull(out.get("schTheme"));
assertNull(out.get("avatarSch"));
assertNull(out.get("logoUrl"));
verify(logoRepository, never()).findBySchIdAndType(any(), any());
}
@Test
@DisplayName("聚合:本校 Logo 优先于系统 Logo;本校无 → 系统兜底;系统也无 → null")
void logoPreference() {
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
when(schSettingsService.findBySchIdRaw(3)).thenReturn(Optional.empty());
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.emptyList());
when(logoRepository.findBySchIdAndType(0, 0)).thenReturn(Collections.singletonList(logo("/upload/logo/sys.png")));
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sys.png", service.getAppearance(user).get("logoUrl"));
when(logoRepository.findBySchIdAndType(3, 1)).thenReturn(Collections.singletonList(logo("/upload/logo/sch.png")));
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl"));
when(logoRepository.findBySchIdAndType(eq(3), eq(1))).thenReturn(Arrays.asList(logo(""), logo("/upload/logo/sch.png")));
assertEquals("http://base.yunhaixingtong.com/userhome/upload//logo/sch.png", service.getAppearance(user).get("logoUrl"));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
assertNull(service.getAppearance(user).get("logoUrl"));
}
@Test
@DisplayName("upsert:已有一行 → 更新原行;themeState=null 置 NULL(跟随学校默认)")
void upsertExistingSetNull() {
UserAppearance ua = new UserAppearance();
ua.setId(7);
ua.setUserId(10133);
ua.setThemeState("ocean|1|0|1");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
service.saveMyTheme(10133, null);
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
verify(userAppearanceRepository).save(captor.capture());
assertEquals(7, captor.getValue().getId());
assertNull(captor.getValue().getThemeState());
assertNotNull(captor.getValue().getUDate());
}
@Test
@DisplayName("upsert:无行 → 插入新行(含 cDate)")
void upsertInsert() {
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
service.saveMyAvatarStyle(10133, "silhouette");
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
verify(userAppearanceRepository).save(captor.capture());
assertEquals(10133, captor.getValue().getUserId());
assertEquals("silhouette", captor.getValue().getAvatarStyle());
assertNotNull(captor.getValue().getCDate());
}
@Test
@DisplayName("saveMyTheme:非法值抛 DException(紧凑串不再可写/坏 JSON/缺字段/非法档名);合法 JSON trim 后规范化存")
void saveMyThemeValidation() {
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> service.saveMyTheme(10133, "red|0|1|1"));
assertThrows(DException.class, () -> service.saveMyTheme(10133, "teal|2|1|1"));
assertThrows(DException.class, () -> service.saveMyTheme(10133, "teal|0|1|1"), "紧凑串不再可写");
assertThrows(DException.class, () -> service.saveMyTheme(10133, "{\"theme\":\"teal\"}"));
service.saveMyTheme(10133, " {\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":true,\"sideFollow\":true} ");
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
verify(userAppearanceRepository).save(captor.capture());
assertEquals("{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}",
captor.getValue().getThemeState(), "trim + 0/1 布尔归一为规范化 JSON");
}
@Test
@DisplayName("saveMyAvatarStyle:非法值抛 DException;null 置 NULL")
void saveMyAvatarStyleValidation() {
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
assertThrows(DException.class, () -> service.saveMyAvatarStyle(10133, "circle"));
service.saveMyAvatarStyle(10133, null);
ArgumentCaptor<UserAppearance> captor = ArgumentCaptor.forClass(UserAppearance.class);
verify(userAppearanceRepository).save(captor.capture());
assertNull(captor.getValue().getAvatarStyle());
}
// ===== 生效值合成(oapi 代理轮 2026-10-08,/oapi/appearance 出参真源)=====
private IUserCurrentRoleService userCurrentRoleService;
private void stubCurrentRole(Integer schId) {
userCurrentRoleService = mock(IUserCurrentRoleService.class);
ReflectionTestUtils.setField(service, "userCurrentRoleService", userCurrentRoleService);
if (schId == null) {
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.empty());
} else {
com.yunhai.userhome.entity.UserCurrentRole role = new com.yunhai.userhome.entity.UserCurrentRole();
role.setUserId(10133);
role.setSchId(schId);
when(userCurrentRoleService.findById(10133)).thenReturn(Optional.of(role));
}
}
@Test
@DisplayName("生效值:个人自选优先(旧紧凑值兼容转 JSON)——theme/avatar 取 user_appearance,级联读不出站")
void effective_personalOverridesCascade() {
stubCurrentRole(3);
UserAppearance ua = new UserAppearance();
ua.setThemeState("ocean|1|0|1"); // 旧紧凑值:读侧兼容
ua.setAvatarStyle("silhouette");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
com.fasterxml.jackson.databind.JsonNode theme = (com.fasterxml.jackson.databind.JsonNode) out.get("theme");
assertEquals("ocean", theme.get("theme").asText());
assertTrue(theme.get("sideDeep").asBoolean());
assertFalse(theme.get("gradOn").asBoolean());
assertEquals("silhouette", out.get("avatar"));
assertFalse(out.containsKey("logoText"), "logoText 已定稿摘除(子应用不消费 LOGO 文字)");
assertNull(out.get("logoUrl"));
verify(schSettingsService, never()).readThemeWithFallback(any());
verify(schSettingsService, never()).readAvatarWithFallback(any());
verify(schSettingsService, never()).readLogoTextWithFallback(any());
}
@Test
@DisplayName("生效值:个人未设 → 学校/平台级联(theme 出 JSON 对象),出参恰三字段")
void effective_fallsToCascade() {
stubCurrentRole(3);
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.empty());
when(schSettingsService.readThemeWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("{\"theme\":\"ink\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":false}", 0));
when(schSettingsService.readAvatarWithFallback(3))
.thenReturn(new ISchSettingsService.LayerValue("text", 3));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
com.fasterxml.jackson.databind.JsonNode theme = (com.fasterxml.jackson.databind.JsonNode) out.get("theme");
assertEquals("ink", theme.get("theme").asText(), "本校空回落平台档(hitSchId=0)");
assertFalse(theme.get("sideDeep").asBoolean());
assertTrue(theme.get("gradOn").asBoolean());
assertEquals("text", out.get("avatar"), "本校已配直接生效(hitSchId=本校)");
// 出参只三字段(logoText 已摘),不外露 my/sch 拆分
assertEquals(3, out.size());
assertTrue(out.containsKey("theme") && out.containsKey("avatar") && out.containsKey("logoUrl"));
}
@Test
@DisplayName("生效值:个人脏值视同未配置 → 落级联;两级皆无 → null;无当前角色 → 平台层(0)")
void effective_dirtyAndNoRole_fallsToPlatformLayer() {
stubCurrentRole(null);
UserAppearance ua = new UserAppearance();
ua.setThemeState("default-theme");
ua.setAvatarStyle("circle");
when(userAppearanceRepository.findByUserId(10133)).thenReturn(Optional.of(ua));
when(schSettingsService.readThemeWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(schSettingsService.readAvatarWithFallback(0))
.thenReturn(new ISchSettingsService.LayerValue(null, 0));
when(logoRepository.findBySchIdAndType(any(), any())).thenReturn(Collections.emptyList());
Map<String, Object> out = service.getEffectiveAppearance(10133);
assertNull(out.get("theme"), "脏值落级联且两级皆无 → null");
assertNull(out.get("avatar"));
assertNull(out.get("logoUrl"));
assertEquals(3, out.size());
verify(schSettingsService).readThemeWithFallback(0);
verify(schSettingsService).readAvatarWithFallback(0);
}
}
@@ -0,0 +1,185 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.userhome.config.MsgCenterProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.verifyNoInteractions;
import static org.mockito.Mockito.when;
/**
* 卡片直批代理单测(B3 §2):taskId 匹配三态(0/1/&gt;1)、
* <b>红线=身份只从会话参数出站</b>(operatorId/schId/operatorName 来自控制器入参,前端只有
* msgId/action/opinion)、业务拒绝原文透出、flow-center 不可达降级。
* 契约冻结(源码实测):flow-center 待办消息 bizId=办件 id → 匹配键 instanceId;
* bizType 过滤键=消息 jumpApp(流程业务类型),非消息分类 flow.task。
*/
@DisplayName("MsgApprovalProxyService - 卡片直批")
class MsgApprovalProxyServiceTest {
private static final String FLOW_CENTER_BASE = "http://flowcenter-service:10008/flow-center";
private YschOpenCall openCall;
private MsgCenterProxyService msgProxy;
private MsgCenterProperties properties;
private MsgApprovalProxyService service;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
msgProxy = mock(MsgCenterProxyService.class);
properties = new MsgCenterProperties();
properties.setFlowCenterBaseUrl(FLOW_CENTER_BASE);
service = new MsgApprovalProxyService(openCall, msgProxy, properties);
}
private static JsonNode json(String s) throws Exception {
return new ObjectMapper().readTree(s);
}
/** flow-center 发的待办消息(notifyTaskCreated 契约):bizId=办件 id、jumpApp=流程业务类型 */
private JsonNode flowTaskMessage() throws Exception {
return json("{\"msgId\":5,\"msgType\":2,\"bizType\":\"flow.task\","
+ "\"bizId\":\"123\",\"jumpApp\":\"notice-approval\",\"isRead\":0}");
}
private void stubTodo(String content) throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":1,\"content\":[" + content + "]}"));
}
@Test
void rejectWithoutOpinion_rejectedBeforeAnyOutbound() {
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "reject", " ");
assertFalse((Boolean) out.get("ok"));
assertEquals("拒绝必须填写意见", out.get("message"));
verifyNoInteractions(openCall, msgProxy);
}
@Test
void invalidAction_rejected() {
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "delegate", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("不支持的操作类型", out.get("message"));
verifyNoInteractions(openCall, msgProxy);
}
@Test
void messageNotFound_stopsWithoutFlowcenterCall() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(null);
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("消息不存在或已失效", out.get("message"), "含他人消息/已删除——统一文案防探测");
verifyNoInteractions(openCall);
}
@Test
void todoZeroMatch_reportProcessed() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("{\"total\":0,\"content\":[]}"));
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("该待办已处理或已失效", out.get("message"));
}
@Test
void flowCenterUnreachable_distinguishedFromZeroMatch() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
when(openCall.get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), anyMap(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("流程中心暂不可用,请稍后重试", out.get("message"), "不可达 ≠ 已处理,降级提示不误导");
}
@Test
void singleMatch_approve_postsSessionIdentity_andMarksRead() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\","
+ "\"bizId\":\"9001\",\"assigneeId\":201}");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
.thenReturn(null);
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", "同意");
assertTrue((Boolean) out.get("ok"));
assertEquals("办理成功", out.get("message"));
// 红线断言:出站身份 = 会话入参(schId=3 / operatorId=201 / operatorName=张三),前端无身份入参
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> postQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/flow/task/77/approve"),
postQuery.capture(), any(), eq(JsonNode.class));
assertEquals("3", postQuery.getValue().get("schId"));
assertEquals("201", postQuery.getValue().get("operatorId"));
assertEquals("张三", postQuery.getValue().get("operatorName"));
ArgumentCaptor<List<Long>> readIds = ArgumentCaptor.forClass(List.class);
verify(msgProxy).read(eq(201), readIds.capture(), any());
assertTrue(readIds.getValue().contains(5L), "办理成功后标记已读");
}
@Test
void todoFilteredByJumpApp_notMessageBizType() throws Exception {
// bizType 过滤键 = 消息 jumpApp(流程业务类型 notice-approval),不是消息分类 flow.task
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\",\"bizId\":\"9001\"}");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
.thenReturn(null);
service.handle(201, 3, "张三", 5L, "approve", null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> todoQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/flow/tasks/todo"), todoQuery.capture(),
eq(JsonNode.class));
assertEquals("notice-approval", todoQuery.getValue().get("bizType"), "消息分类 flow.task 与待办 bizType 不同命名空间");
assertEquals("201", todoQuery.getValue().get("assigneeId"), "待办按登录人维度拉取");
}
@Test
void multiMatch_sameInstanceMultipleNodes_goDetail() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\"},"
+ "{\"taskId\":78,\"instanceId\":123,\"bizType\":\"notice-approval\"}");
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("同一办件匹配到多个待办节点,请前往详情页办理", out.get("message"));
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class));
}
@Test
void businessReject_passthroughServerMessage() throws Exception {
when(msgProxy.findMessage(201, 5L)).thenReturn(flowTaskMessage());
stubTodo("{\"taskId\":77,\"instanceId\":123,\"bizType\":\"notice-approval\"}");
when(openCall.post(any(TargetSpec.class), anyString(), anyMap(), any(), eq(JsonNode.class)))
.thenThrow(new DException("当前用户不是该任务办理人"));
Map<String, Object> out = service.handle(201, 3, "张三", 5L, "approve", null);
assertFalse((Boolean) out.get("ok"));
assertEquals("当前用户不是该任务办理人", out.get("message"), "业务拒绝原文透出(flow-center 逐字复核)");
verify(msgProxy, never()).read(any(), any(), any());
}
}
@@ -5,21 +5,30 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.OpenApiException; import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.TargetSpec; import com.yunhai.platform.openapi.core.TargetSpec;
import com.yunhai.platform.openapi.core.YschOpenCall; import com.yunhai.platform.openapi.core.YschOpenCall;
import com.yunhai.platform.openapi.msg.model.AppMsgCount;
import com.yunhai.platform.openapi.msg.model.MsgListPage;
import com.yunhai.platform.openapi.msg.model.ReadAllMsgRequest;
import com.yunhai.platform.openapi.msg.model.UserMsgSummary;
import com.yunhai.userhome.config.MsgCenterProperties; import com.yunhai.userhome.config.MsgCenterProperties;
import com.yunhai.userhome.entity.UserCurrentRole;
import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor; import org.mockito.ArgumentCaptor;
import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyMap; import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString; import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.contains;
import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never; import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
@@ -27,48 +36,212 @@ import static org.mockito.Mockito.when;
/** /**
* 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约 * 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)与 * (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)、
* jumpUrl 改写规则(jumpType=1 + bizType 命中基址 → 基址 + "/#" + jumpPath)。 * jumpUrl 改写规则与 showJump 判权出参(C1/C2 收口后口径)。
* UserMsg 不可达降级空结果(铃铛不 500)。 * <p>
* jumpUrl(C2 收口):jumpType=1 时绝对 URL 直接透传;相对路径走应用注册表严格二选一——
* jumpApp(值域=app_code)有值只认 by-code 结果,jumpApp 为空才按发送方 appKey 走 by-key
* (自发场景);pc_url 空就是空,不再回落、jump-bases 手工基址表已退役。
* <p>
* showJump(C1 判权):jumpApp 有值且 jumpUrl 非空时按 /oapi/app/authorized-codes 集合判定;
* 无权 → showJump=false 且 jumpUrl 一并置 null(双保险);jumpApp 为空 / UserBase 不可达不判权放行;
* 判权集合每请求懒加载一次,行间共享。
* <p> * <p>
* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0): * 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0):
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时 * 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从 * 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。 * usermsg-base-url 解析出 basePath=/usermsg(per-target 剥离),调用 path 为 ctx 裸路径。
* <p>
* 按学校隔离(2026-10-09 批次3):消息读取(list/summary/unreadCount/countByApp/readAll)改走
* SDK msg 域类型化读方法(Tier 1,仍委托同一个 {@link YschOpenCall},故断言靶心从 JsonNode
* 换成 DTO class:{@code MsgListPage/UserMsgSummary/Long/AppMsgCount/Void}),并带
* <b>当前激活身份</b>学校口径({@link MsgCenterProxyService#resolveReadSchId},R2);
* 待办角标 todo/list 仍按主学校口径(§8 不动角标链路),两者在 summary 上是两个独立入参。
*/ */
@DisplayName("MsgCenterProxyService - YschOpenCall 代理与 jumpUrl 改写") @DisplayName("MsgCenterProxyService - SDK 类型化读取、学校口径下行与 jumpUrl/showJump 改写")
class MsgCenterProxyServiceTest { class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */ /** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg"; private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
private static final ObjectMapper MAPPER = new ObjectMapper();
private YschOpenCall openCall; private YschOpenCall openCall;
private IUserCurrentRoleService userCurrentRoleService;
private MsgCenterProperties properties; private MsgCenterProperties properties;
private MsgCenterProxyService service; private MsgCenterProxyService service;
@BeforeEach @BeforeEach
void setUp() { void setUp() {
openCall = mock(YschOpenCall.class); openCall = mock(YschOpenCall.class);
userCurrentRoleService = mock(IUserCurrentRoleService.class);
properties = new MsgCenterProperties(); properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL); properties.setUsermsgBaseUrl(PROD_BASE_URL);
properties.getJumpBases().put("flow.task", "http://localhost:8088/"); service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
service = new MsgCenterProxyService(openCall, properties);
} }
private static JsonNode json(String s) throws Exception { private static JsonNode json(String s) throws Exception {
return new ObjectMapper().readTree(s); return MAPPER.readTree(s);
} }
/** SDK 类型化读方法的响应靶心:同一份 JSON 夹具直接绑成 SDK DTO(契约字段与 UserMsg 出参对齐) */
private static <T> T dto(String s, Class<T> type) throws Exception {
return MAPPER.readValue(s, type);
}
private static <E> java.util.List<E> listDto(String s, Class<E> elementType) throws Exception {
return MAPPER.readValue(s, MAPPER.getTypeFactory()
.constructCollectionType(java.util.List.class, elementType));
}
/** 当前身份 stub(R2 数据源):findById(userId) 返回给定 schId 的激活身份 */
private void stubCurrentIdentity(Integer schId) {
UserCurrentRole role = new UserCurrentRole();
role.setUserId(201);
role.setRoleId(9);
role.setSchId(schId);
when(userCurrentRoleService.findById(201)).thenReturn(Optional.of(role));
}
/** 判权 stub:/oapi/app/authorized-codes 返回给定 app_code 集合 */
private void stubAuthorizedCodes(String... codes) throws Exception {
StringBuilder arr = new StringBuilder("[");
for (int i = 0; i < codes.length; i++) {
if (i > 0) {
arr.append(',');
}
arr.append('"').append(codes[i]).append('"');
}
arr.append(']');
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenReturn(json(arr.toString()));
}
/** 应用字典 stub:/oapi/app/list 返回单分组单应用(appId 翻译 / countByApp 富化数据源) */
private void stubAppDict(int appId, String appKey, String name) throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"apps\":[{\"id\":" + appId + ",\"appKey\":\"" + appKey
+ "\",\"name\":\"" + name + "\"}]}]"));
}
/** 应用字典 stub(多应用形态):合桶/双值翻译验证两桶同归一应用 */
private void stubAppDictRaw(String groupsJson) throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json(groupsJson));
}
// ===== R2:读取口径 schId 必须来自当前激活身份(严禁会话主学校) =====
@Test
void resolveReadSchId_usesCurrentIdentitySchool() {
stubCurrentIdentity(7);
assertEquals(Integer.valueOf(7), service.resolveReadSchId(201), "当前身份 schId>0 → 原值下行(本校+平台可见)");
}
@Test
void resolveReadSchId_platformIdentityZeroMeansSeeAll() {
stubCurrentIdentity(0);
assertNull(service.resolveReadSchId(201), "schId=0(平台/超管)→ null=看全部(D5)");
stubCurrentIdentity(null);
assertNull(service.resolveReadSchId(201), "身份无 schId 同样按看全部处理");
}
@Test
void resolveReadSchId_identityChainDown_seesAllWithoutThrowing() {
when(userCurrentRoleService.findById(201)).thenThrow(new OpenApiException("userbase down"));
assertNull(service.resolveReadSchId(201), "身份链不可达降级 null=改造前全量口径,铃铛不 500");
assertNull(service.resolveReadSchId(null), "无登录人不出站");
verify(userCurrentRoleService, never()).findById(null);
}
// ===== 批次3:学校口径随读端点下行(R1 的下游半程) =====
@Test
void list_andSummary_passSchIdDownstream() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(dto("{\"unreadCount\":1}", UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
service.list(201, 1, 10, null, null, null, null, null, 7);
service.summary(201, 7, 3);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> listQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), listQuery.capture(), eq(MsgListPage.class));
assertEquals("7", listQuery.getValue().get("schId"));
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> summaryQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/summary"), summaryQuery.capture(),
eq(UserMsgSummary.class));
assertEquals("7", summaryQuery.getValue().get("schId"), "摘要未读/分类计数按当前身份学校口径");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> todoQuery = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), todoQuery.capture(), eq(JsonNode.class));
assertEquals("3", todoQuery.getValue().get("schId"), "待办角标沿用主学校口径(§8 链路不变)");
}
@Test
void countByApp_passesSchIdAndEnriches() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":4,\"unread\":1}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, 7);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), query.capture(),
eq(AppMsgCount.class));
assertEquals("7", query.getValue().get("schId"));
assertEquals(1, rows.size());
assertEquals(100011, rows.get(0).get("appId"));
assertEquals(4L, rows.get(0).get("total"));
}
@Test
void read_schIdGoesIntoBodyOnlyWhenPresent() {
ArgumentCaptor<Map<String, Object>> body = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(7L), 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body.capture(),
eq(JsonNode.class));
assertEquals(7, body.getValue().get("schId"), "schId 供 UserMsg 侧缓存失效键与 readState 口径(D8)");
assertEquals(201, body.getValue().get("userId"));
ArgumentCaptor<Map<String, Object>> body2 = ArgumentCaptor.forClass(Map.class);
service.read(201, java.util.Collections.singletonList(8L), null);
verify(openCall, org.mockito.Mockito.times(2))
.post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), body2.capture(), eq(JsonNode.class));
assertFalse(body2.getAllValues().get(1).containsKey("schId"), "null 不下发=平台视角(与历史调用一致)");
}
@Test
void readAll_goesThroughSdkWithSchId() {
ArgumentCaptor<ReadAllMsgRequest> body = ArgumentCaptor.forClass(ReadAllMsgRequest.class);
service.readAll(201, 7);
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/readAll"), anyMap(), body.capture(),
eq(Void.class));
assertEquals(Integer.valueOf(201), body.getValue().getUserId());
assertEquals(Integer.valueOf(7), body.getValue().getSchId(), "全部已读按当前身份学校限定(D6)");
}
// ===== 寻址回归(160 事故)与 list 改写 =====
@Test @Test
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() { void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg // 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 1, 10, null); service.list(201, 1, 10, null, null, null, null, null, null);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class); ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class)); verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost()); assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost());
assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名"); assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名");
} }
@@ -76,41 +249,49 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_buildsQueryParams_andRewritesJumpUrl() throws Exception { void list_buildsQueryParams_andRewritesJumpUrl() throws Exception {
properties.getAppNames().put("100617", "通知公告"); properties.getAppNames().put("100617", "通知公告");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) // 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
.thenReturn(json("{\"total\":2,\"content\":[" stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2," + "{\"msgId\":1,\"title\":\"新待办\",\"content\":\"您有新的审批任务\",\"msgType\":2,"
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\"," + "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
+ "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0}," + "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0},"
+ "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0," + "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0,"
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}")); + "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE); Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, null, null, null);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("201", query.getValue().get("userId")); assertEquals("201", query.getValue().get("userId"));
assertEquals("2", query.getValue().get("pageIndex")); assertEquals("2", query.getValue().get("pageIndex"));
assertEquals("20", query.getValue().get("pageSize")); assertEquals("20", query.getValue().get("pageSize"));
assertEquals("true", query.getValue().get("onlyUnread")); assertEquals("true", query.getValue().get("onlyUnread"));
assertFalse(query.getValue().containsKey("schId"), "schId=null 不下发(向后兼容,验收 7)");
assertEquals(2, out.get("total")); assertEquals(2, out.get("total"));
java.util.List<?> rows = (java.util.List<?>) out.get("list"); java.util.List<?> rows = (java.util.List<?>) out.get("list");
Map<?, ?> first = (Map<?, ?>) rows.get(0); Map<?, ?> first = (Map<?, ?>) rows.get(0);
assertEquals("http://localhost:8088/#/task/123", first.get("jumpUrl"), "基址+/#+裸路径"); assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
assertEquals("flow.task", first.get("bizType")); assertEquals("flow.task", first.get("bizType"));
assertEquals("2026-09-13 10:00:00", first.get("sendTime"), "sendTime 逐字段透传(SDK 侧 String 口径)");
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名"); assertNull(first.get("appName"), "未配置映射的 appKey 不补应用名");
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"), "非应用内跳转不改写"); Map<?, ?> second = (Map<?, ?>) rows.get(1);
assertNull(second.get("jumpUrl"), "非应用内跳转不改写");
assertEquals(Boolean.FALSE, second.get("showJump"));
} }
@Test @Test
void list_defaultsPageParams() { void list_defaultsPageParams() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null); .thenReturn(null);
service.list(201, 0, 999, null); service.list(201, 0, 999, null, null, null, null, null, null);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(JsonNode.class)); verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1"); assertEquals("1", query.getValue().get("pageIndex"), "非法页码回退 1");
assertEquals("100", query.getValue().get("pageSize"), "页长上限 100"); assertEquals("100", query.getValue().get("pageSize"), "页长上限 100");
assertEquals("false", query.getValue().get("onlyUnread")); assertEquals("false", query.getValue().get("onlyUnread"));
@@ -119,30 +300,126 @@ class MsgCenterProxyServiceTest {
@Test @Test
void list_appNameMappedFromAppKey() throws Exception { void list_appNameMappedFromAppKey() throws Exception {
properties.getAppNames().put("100617", "通知公告"); properties.getAppNames().put("100617", "通知公告");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\"," .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}")); + "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}",
Map<String, Object> out = service.list(201, 1, 10, null); MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名"); assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
} }
/** jumpApp 与 appKey 均空 → 注册表查址无源可查 → null(前端不渲染跳转),且无 by-code/by-key 出站。
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
@Test @Test
void list_noBaseConfigured_jumpUrlNull() throws Exception { void list_noRegistrySource_jumpUrlNull() throws Exception {
properties.getJumpBases().clear(); when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) .thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
.thenReturn(json("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1," + "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}")); Map<?, ?> first = firstRow();
Map<String, Object> out = service.list(201, 1, 10, null); assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
Map<?, ?> first = (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0); assertEquals(Boolean.FALSE, first.get("showJump"));
assertNull(first.get("jumpUrl"), "bizType 未配基址 → 不改写(前端不渲染跳转)"); verify(openCall, never()).get(eq("userbase"), contains("by-code"), isNull(), eq(JsonNode.class));
verify(openCall, never()).get(eq("userbase"), contains("by-key"), isNull(), eq(JsonNode.class));
verify(openCall, org.mockito.Mockito.times(1))
.get(eq("userbase"), eq("/oapi/app/list"), anyMap(), eq(JsonNode.class));
}
// ===== Phase 2(自指地址优化):归属优先 + 绝对 URL 透传 =====
/** 归属应用 jumpApp 主查注册表 by-code,pc_url 拼接 + 判权放行 */
@Test
void list_jumpApp_byCodeHit_authorized_showJumpTrue() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\","
+ "\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice", "flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/detail?serid=1\",\"bizType\":\"flow.task\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"归属应用 jumpApp 主查 by-code");
assertEquals("notice", first.get("jumpApp"), "jumpApp 逐字段透传");
assertEquals(Boolean.TRUE, first.get("showJump"), "有权 → 显示跳转入口");
}
/** 存量兼容:改造前落库的绝对 URL 直接透传,不再拼基址(B5;双前缀缺陷即源于二次拼接) */
@Test
void list_absoluteJumpPath_passedThrough() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/detail?serid=1\","
+ "\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/notice/#/list/detail?serid=1", first.get("jumpUrl"),
"绝对 URL 原样透传");
assertEquals(Boolean.TRUE, first.get("showJump"), "无 jumpApp 不判权放行");
}
/** 旧发送端未升级(无 jumpApp)的自发消息 → 按发送方 appKey 走 by-key(严格二选一的第二分支) */
@Test
void list_jumpAppAbsent_fallsBackToSendersAppKey() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"appKey\":\"100019\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8088#/task/9", first.get("jumpUrl"), "无归属声明回落发送方 appKey");
assertEquals(Boolean.TRUE, first.get("showJump"));
}
/** jumpApp 查无 pc_url 且 appKey 为空 → null(不做猜测) */
@Test
void list_jumpAppMiss_noAppKeyFallback_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/unknown-app", null);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"bizType\":\"flow.task\",\"jumpApp\":\"unknown-app\","
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"));
assertEquals(Boolean.FALSE, first.get("showJump"));
}
/** P2 缺陷回归:jumpApp 有值但注册表查无 pc_url → 严格 null,绝不回落发送方 appKey */
@Test
void list_jumpAppPresent_byCodeMiss_noSenderAppKeyFallback() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice", null);
stubUserbaseGet("/oapi/app/by-key/100006",
"{\"id\":100006,\"appKey\":100006,\"name\":\"成绩管理\",\"pcUrl\":\"http://localhost:8090\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/score/9\",\"appKey\":\"100006\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "jumpApp 命中即只认注册表结果,空就是空");
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/by-key/100006"), isNull(), eq(JsonNode.class));
}
/** pc_url 尾斜杠归一:注册表基址带尾斜杠也不产出 //(归一化并入注册表路径) */
@Test
void list_registryPcUrlTrailingSlash_normalized() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077/\"}");
stubAuthorizedCodes("notice");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"list/detail?serid=1\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://localhost:8077#/list/detail?serid=1", first.get("jumpUrl"),
"尾斜杠归一 + 无前导斜杠路径补斜杠");
} }
@Test @Test
void list_userMsgDown_degradesToEmpty() { void list_userMsgDown_degradesToEmpty() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401")); .thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
Map<String, Object> out = service.list(201, 1, 10, null); Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
assertEquals(0, out.get("total")); assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size()); assertEquals(0, ((java.util.List<?>) out.get("list")).size());
} }
@@ -175,36 +452,151 @@ class MsgCenterProxyServiceTest {
@Test @Test
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception { void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("3")); .thenReturn(3L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]")); .thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]"));
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(3L, out.get("unreadCount")); assertEquals(3L, out.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out.get("todos")).size());
// appKey 属后端签名标识不下发(0e57022 口径收尾):待办行只出 count
@SuppressWarnings("unchecked")
Map<String, Object> todoRow = (Map<String, Object>) ((java.util.List<?>) out.get("todos")).get(0);
assertFalse(todoRow.containsKey("appKey"), "待办角标不再下发 appKey");
assertEquals(2, todoRow.get("count"));
// 未读数不可达单独降级为 0,待办仍可用 // 未读数不可达单独降级为 0,待办仍可用
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenThrow(new OpenApiException("down")); .thenThrow(new OpenApiException("down"));
Map<String, Object> out2 = service.summary(201, 1); Map<String, Object> out2 = service.summary(201, null, 1);
assertEquals(0L, out2.get("unreadCount")); assertEquals(0L, out2.get("unreadCount"));
assertEquals(1, ((java.util.List<?>) out2.get("todos")).size()); assertEquals(1, ((java.util.List<?>) out2.get("todos")).size());
} }
/** /msg 页服务端筛选(消息对接 2026-09-30;appId 口径 2026-10-08):msgType/appId/isRead/keyword 透传,
* 空值不下发。appId 不再由前端直传 appKey:先经应用字典翻译(去首尾空白),字典查无 → 空页且不出站 UserMsg。 */
@Test @Test
void read_sendsOwnerScopedPayload() { void list_filtersPassedThrough() throws Exception {
service.read(201, java.util.Collections.singletonList(7L)); stubAppDict(100019, "100019", "流程中心");
verify(openCall).post(any(TargetSpec.class), eq("/oapi/msg/read"), anyMap(), any(Map.class), when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
eq(JsonNode.class)); .thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
service.list(201, 1, 10, null, 2, " 100019 ", 0, "请假", null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("2", query.getValue().get("msgType"));
assertEquals("100019", query.getValue().get("appKeys"), "appId 去空白经字典翻译为归属口径 appKeys 下行");
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方 appKey 口径");
assertEquals("0", query.getValue().get("isRead"));
assertEquals("请假", query.getValue().get("keyword"));
// 全空筛选不出现新键(与历史调用逐字一致)
service.list(201, 1, 10, null, null, " ", null, "", null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query2 = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2))
.get(any(TargetSpec.class), eq("/oapi/msg/list"), query2.capture(), eq(MsgListPage.class));
assertFalse(query2.getAllValues().get(1).containsKey("msgType"));
assertFalse(query2.getAllValues().get(1).containsKey("appKey"));
assertFalse(query2.getAllValues().get(1).containsKey("isRead"));
assertFalse(query2.getAllValues().get(1).containsKey("keyword"));
}
/** appId 字典查无(授权回收等)→ 视作该应用无消息:空页,且不出站 UserMsg 列表 */
@Test
void list_appIdUnknownInDict_emptyPageNoUserMsgOutbound() throws Exception {
// 字典未 stub → /oapi/app/list 返回 null → EMPTY
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, "999999", null, null, null);
assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
}
/** 按应用计数(0e57022):UserMsg countByApp 条目经应用字典富化出 appId/appName,总数口径;
* 字典查无 → appId/appName null(前端不展示);无 appKey 的行跳过。 */
@Test
void countByApp_enrichesWithRegistryDict() throws Exception {
stubAppDict(100011, "100019", "流程中心");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"100019\",\"total\":12,\"unread\":3},"
+ "{\"appKey\":\"999999\",\"total\":5,\"unread\":1},"
+ "{\"total\":2,\"unread\":0}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "无 appKey 行跳过");
Map<String, Object> first = rows.get(0);
assertEquals(100011, first.get("appId"));
assertEquals("流程中心", first.get("appName"));
assertEquals(12L, first.get("total"));
assertEquals(3L, first.get("unread"));
assertNull(rows.get(1).get("appId"), "字典查无 appId=null,行保留由前端隐藏");
assertNull(rows.get(1).get("appName"));
assertEquals(5L, rows.get(1).get("total"));
}
/** countByApp 停机/未启用降级空列表(「按应用」清单空态,不 500) */
@Test
void countByApp_disabledOrDown_degradesEmpty() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertTrue(service.countByApp(201, null).isEmpty());
properties.setUsermsgBaseUrl(PROD_BASE_URL);
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenThrow(new OpenApiException("down"));
assertTrue(service.countByApp(201, null).isEmpty(), "UserMsg 不可达降级空,不抛出");
}
/** 摘要优先走 /oapi/msg/summary(分类计数一次喂饱),且不再单查 unreadCount */
@Test
void summary_prefersSummaryEndpoint() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenReturn(dto("{\"unreadCount\":5,\"sysUnread\":2,\"bizUnread\":1,\"approvalUnread\":2,"
+ "\"total\":9,\"readCount\":4,\"sysTotal\":3,\"bizTotal\":2,\"approvalTotal\":4}",
UserMsgSummary.class));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(5L, out.get("unreadCount"));
assertEquals(2L, out.get("sysUnread"));
assertEquals(1L, out.get("bizUnread"));
assertEquals(2L, out.get("approvalUnread"));
assertEquals(9L, out.get("total"));
assertEquals(4L, out.get("readCount"));
assertEquals(3L, out.get("sysTotal"));
verify(openCall, never()).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class));
}
/** 旧版 UserMsg 无 summary 端点(404/异常)→ 降级 unreadCount 单查,分类字段缺省 */
@Test
void summary_legacyUserMsg_fallsBackToUnreadCount() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/summary"), anyMap(), eq(UserMsgSummary.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=404"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(7L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
Map<String, Object> out = service.summary(201, 7, 1);
assertEquals(7L, out.get("unreadCount"));
assertFalse(out.containsKey("sysUnread"), "旧端点无分类计数,字段缺省由前端降级");
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), query.capture(), eq(Long.class));
assertEquals("7", query.getValue().get("schId"), "降级单查同样带当前身份学校口径");
} }
@Test @Test
void disabled_degradesWithoutOutbound() { void disabled_degradesWithoutOutbound() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false); when(openCall.hasTarget("usermsg")).thenReturn(false);
assertEquals(0, service.list(201, 1, 10, null).get("total")); assertEquals(0, service.list(201, 1, 10, null, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L)); service.read(201, java.util.Collections.singletonList(7L), null);
verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any()); verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any()); verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
} }
@@ -214,9 +606,9 @@ class MsgCenterProxyServiceTest {
// base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用 // base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(true); when(openCall.hasTarget("usermsg")).thenReturn(true);
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("5")); .thenReturn(5L);
Map<String, Object> out = service.summary(201, null); Map<String, Object> out = service.summary(201, null, null);
assertEquals(5L, out.get("unreadCount")); assertEquals(5L, out.get("unreadCount"));
} }
@@ -224,19 +616,19 @@ class MsgCenterProxyServiceTest {
void summary_disabled_degradesToZero() { void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl(""); properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false); when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount")); assertEquals(0L, out.get("unreadCount"));
assertEquals(0, ((java.util.List<?>) out.get("todos")).size()); assertEquals(0, ((java.util.List<?>) out.get("todos")).size());
} }
@Test @Test
void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception { void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(json("0")); .thenReturn(0L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class))) when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]")); .thenReturn(json("[]"));
service.summary(201, null); service.summary(201, null, null);
service.summary(201, 3); service.summary(201, null, 3);
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class); ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2)) verify(openCall, org.mockito.Mockito.times(2))
@@ -248,7 +640,199 @@ class MsgCenterProxyServiceTest {
@Test @Test
void malformedBaseUrl_disabledNotCrash() { void malformedBaseUrl_disabledNotCrash() {
properties.setUsermsgBaseUrl("::::not-a-url"); properties.setUsermsgBaseUrl("::::not-a-url");
Map<String, Object> out = service.summary(201, 1); Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出"); assertEquals(0L, out.get("unreadCount"), "非法 base-url 降级而非抛出");
} }
// ===== B2(跳转基址统一):注册表 pc_url 主链路 =====
private void stubUserbaseGet(String path, String body) throws Exception {
when(openCall.get(eq("userbase"), eq(path), isNull(), eq(JsonNode.class)))
.thenReturn(body == null ? null : json(body));
}
private Map<?, ?> firstRow() throws Exception {
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
return (Map<?, ?>) ((java.util.List<?>) out.get("list")).get(0);
}
// ===== 消息归属子应用收口(2026-10-13):出参归属换源、countByApp 合桶、list 双值翻译 =====
private void stubMsgList(String contentJson) throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":" + contentJson + "}", MsgListPage.class));
}
/** 换源三态①:jumpApp 可解析 → appId/appName 指向业务方应用(页面属主优先于发送方) */
@Test
void list_jumpAppResolvable_ownershipRewrittenToBusinessApp() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\",\"pcUrl\":\"http://localhost:8077\"}");
stubMsgList("[{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/detail?serid=59\","
+ "\"appKey\":\"100009\",\"jumpApp\":\"notice\",\"bizType\":\"flow.task\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100001, first.get("appId"), "归属出参跟页面属主,不跟发送方");
assertEquals("通知公告", first.get("appName"));
}
/** 换源三态②:jumpApp 脏值查无 → 回落发送方字典口径(现状对齐,appId/appName 走 byKey) */
@Test
void list_jumpAppDirty_fallsBackToSenderDictEntry() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubUserbaseGet("/oapi/app/by-code/svc_item", null);
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":\"svc_item\",\"bizType\":\"flow.task\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100011, first.get("appId"), "脏 jumpApp 回落发送方 appKey 字典");
assertEquals("流程中心", first.get("appName"));
}
/** 换源三态③:jumpApp 为空(办结/催办按拍板留流程中心)→ 不调 by-code,直接发送方口径 */
@Test
void list_jumpAppAbsent_noByCodeCall_senderOwnership() throws Exception {
stubAppDict(100011, "100009", "流程中心");
stubMsgList("[{\"msgId\":1,\"appKey\":\"100009\",\"jumpApp\":null,\"bizType\":\"flow.result\",\"isRead\":0}]");
Map<?, ?> first = firstRow();
assertEquals(100011, first.get("appId"));
assertEquals("流程中心", first.get("appName"));
verify(openCall, never()).get(eq("userbase"), contains("/oapi/app/by-code/"), isNull(), eq(JsonNode.class));
}
/** countByApp 合桶:notice 代发桶 + 100001 自发桶解析到同一注册表主键 → 并一行、计数相加 */
@Test
void countByApp_mergesCodeAndKeyBucketsIntoOneApp() throws Exception {
stubAppDict(100001, "100001", "通知公告");
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100001,\"appKey\":100001,\"name\":\"通知公告\",\"appCode\":\"notice\"}");
when(openCall.getList(any(TargetSpec.class), eq("/oapi/msg/countByApp"), anyMap(), eq(AppMsgCount.class)))
.thenReturn(listDto("[{\"appKey\":\"notice\",\"total\":2,\"unread\":2},"
+ "{\"appKey\":\"100001\",\"total\":1,\"unread\":0},"
+ "{\"appKey\":\"100009\",\"total\":3,\"unread\":1}]", AppMsgCount.class));
java.util.List<Map<String, Object>> rows = service.countByApp(201, null);
assertEquals(2, rows.size(), "notice 与 100001 两桶合并为通知公告一类目");
Map<String, Object> notice = rows.stream().filter(r -> Integer.valueOf(100001).equals(r.get("appId")))
.findFirst().orElseThrow(IllegalStateException::new);
assertEquals("通知公告", notice.get("appName"));
assertEquals(3L, notice.get("total"));
assertEquals(2L, notice.get("unread"));
assertEquals(3L, rows.get(0).get("total"), "合并后仍按总数降序");
}
/** list 的 appId 筛选翻译改双值:app_key + app_code 下行 appKeys,不再下行 appKey */
@Test
void list_appIdFilter_translatesToDualOwnerValues() throws Exception {
stubAppDictRaw("[{\"apps\":[{\"id\":100001,\"appKey\":\"100001\",\"name\":\"通知公告\"},"
+ "{\"id\":100011,\"appKey\":\"100009\",\"name\":\"流程中心\"}]}]");
stubUserbaseGet("/oapi/app/by-key/100001", "{\"id\":100001,\"appKey\":100001,\"appCode\":\"notice\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":0,\"content\":[]}", MsgListPage.class));
service.list(201, 1, 10, null, null, "100001", null, null, null);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall).get(any(TargetSpec.class), eq("/oapi/msg/list"), query.capture(), eq(MsgListPage.class));
assertEquals("100001,notice", query.getValue().get("appKeys"), "自发桶+代发桶一次筛全");
assertFalse(query.getValue().containsKey("appKey"), "归属口径取代发送方口径");
}
/** 注册表命中:pc_url 含 #/route fragment → 丢 fragment、保 index.html、拼 #,无双 hash(§3.2) */
@Test
void list_registryPcUrlWithFragment_normalizedSingleHash() throws Exception {
stubUserbaseGet("/oapi/app/by-code/flow-center",
"{\"id\":100011,\"appKey\":100009,\"name\":\"流程中心\","
+ "\"pcUrl\":\"http://base.yunhaixingtong.com/flow-center/front/index.html#/my\"}");
stubAuthorizedCodes("flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/123\",\"appKey\":\"100009\",\"jumpApp\":\"flow-center\",\"isRead\":0}]}",
MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals("http://base.yunhaixingtong.com/flow-center/front/index.html#/task/123", first.get("jumpUrl"),
"丢 fragment + # 拼接,不产第二个 #");
assertEquals(Boolean.TRUE, first.get("showJump"));
}
// ===== C1(showJump 判权):无权隐藏、不可达放行、每请求一次出站 =====
/** 无权:showJump=false 且 jumpUrl 一并置 null(双保险,前端漏改也不可点) */
@Test
void list_jumpApp_unauthorized_showJumpFalse_jumpUrlNull() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("flow-center");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.FALSE, first.get("showJump"), "无该应用权限 → 隐藏跳转入口");
assertNull(first.get("jumpUrl"), "无权时 jumpUrl 同步置 null(双保险)");
}
/** jumpApp 为空 → 不判权(避免误伤无归属消息),且不触发 authorized-codes 出站 */
@Test
void list_jumpAppBlank_noAuthzOutbound() throws Exception {
stubUserbaseGet("/oapi/app/by-key/100019",
"{\"id\":100011,\"appKey\":100019,\"name\":\"流程中心\",\"pcUrl\":\"http://localhost:8088\"}");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"/task/9\",\"appKey\":\"100019\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"));
verify(openCall, never()).get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
}
/** UserBase 判权不可达 → 不判权放行:绝对 URL 消息 showJump 保持 true(绝不批量隐藏) */
@Test
void list_authzUnreachable_absoluteUrlStillPasses() throws Exception {
when(openCall.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"jumpType\":1,"
+ "\"jumpPath\":\"http://base.yunhaixingtong.com/notice/#/list/1\",\"jumpApp\":\"notice\","
+ "\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertEquals(Boolean.TRUE, first.get("showJump"), "判权通道故障 → 放行");
assertEquals("http://base.yunhaixingtong.com/notice/#/list/1", first.get("jumpUrl"));
}
/** 判权集合每请求懒加载一次:同列表多行带 jumpApp 共用一次出站(禁按行重复) */
@Test
void list_authzFetchedOncePerRequest_sharedAcrossRows() throws Exception {
stubUserbaseGet("/oapi/app/by-code/notice",
"{\"id\":100013,\"appKey\":100001,\"name\":\"通知公告\",\"pcUrl\":\"http://localhost:8077\"}");
stubAuthorizedCodes("notice");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/list/1\",\"jumpApp\":\"notice\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/list/2\",\"jumpApp\":\"notice\",\"isRead\":0}]}",
MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(0)).get("showJump"));
assertEquals(Boolean.TRUE, ((Map<?, ?>) rows.get(1)).get("showJump"));
verify(openCall, org.mockito.Mockito.times(1))
.get(eq("userbase"), eq("/oapi/app/authorized-codes"), anyMap(), eq(JsonNode.class));
}
/** UserBase 停摆:首行查址失败进 30s 短路窗口,后续行不再出站;相对路径无基址可解 → 入口整体隐藏 */
@Test
void list_userbaseDown_shortCircuit_relativeJumpNull() throws Exception {
when(openCall.get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class)))
.thenThrow(new RuntimeException("connect timeout"));
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":2,\"content\":["
+ "{\"msgId\":1,\"jumpType\":1,\"jumpPath\":\"/task/1\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0},"
+ "{\"msgId\":2,\"jumpType\":1,\"jumpPath\":\"/task/2\",\"appKey\":\"100009\",\"bizType\":\"flow.task\",\"isRead\":0}]}",
MsgListPage.class));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
java.util.List<?> rows = (java.util.List<?>) out.get("list");
assertNull(((Map<?, ?>) rows.get(0)).get("jumpUrl"), "注册表不可达无兜底 → null");
assertNull(((Map<?, ?>) rows.get(1)).get("jumpUrl"));
assertEquals(Boolean.FALSE, ((Map<?, ?>) rows.get(0)).get("showJump"));
// 首行仅 by-key 一次(无 jumpApp 不查 by-code)→ 短路后第二行 0 次,共 1 次
verify(openCall, org.mockito.Mockito.times(1)).get(eq("userbase"), anyString(), isNull(), eq(JsonNode.class));
}
} }
@@ -0,0 +1,93 @@
package com.yunhai.userhome.service;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.repository.SchSettingsRepository;
import com.yunhai.userhome.service.impl.SchSettingsServiceImpl;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.when;
/**
* sch_settings 读侧级联单测(LOGO 文字轮 2026-10-09 / 壁纸三级默认轮 2026-10-09):
* 本校未配置 → 平台层(schId=0)回落,LayerValue.hitSchId 标记实际命中层;两级皆空 → null。
*/
class SchSettingsServiceTest {
private SchSettingsRepository schSettingsRepository;
private SchSettingsServiceImpl service;
@BeforeEach
void setUp() {
schSettingsRepository = Mockito.mock(SchSettingsRepository.class);
service = new SchSettingsServiceImpl();
ReflectionTestUtils.setField(service, "schSettingsRepository", schSettingsRepository);
}
private SchSettings row(Integer schId, String logoText, String wallpaperUrl) {
SchSettings s = new SchSettings();
s.setSchId(schId);
s.setLogoText(logoText);
s.setWallpaperUrl(wallpaperUrl);
return s;
}
@Test
@DisplayName("默认壁纸:本校已配置 → 命中本校层(hitSchId=本校)")
void wallpaperSchoolHit() {
when(schSettingsRepository.findFirstBySchId(3))
.thenReturn(Optional.of(row(3, null, "http://base.test/upl/sch.png")));
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
assertEquals("http://base.test/upl/sch.png", hit.getValue());
assertEquals(3, hit.getHitSchId());
}
@Test
@DisplayName("默认壁纸:本校未配置 → 回落平台层(hitSchId=0)")
void wallpaperFallsToPlatform() {
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.empty());
when(schSettingsRepository.findFirstBySchId(0))
.thenReturn(Optional.of(row(0, null, "http://base.test/upl/platform.png")));
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
assertEquals("http://base.test/upl/platform.png", hit.getValue());
assertEquals(0, hit.getHitSchId());
}
@Test
@DisplayName("默认壁纸:本校空白 → 回落平台层,平台也未配置 → value=null、hitSchId=0(命中层口径)")
void wallpaperBothEmpty() {
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.of(row(3, null, " ")));
when(schSettingsRepository.findFirstBySchId(0)).thenReturn(Optional.empty());
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(3);
assertNull(hit.getValue());
assertEquals(0, hit.getHitSchId());
}
@Test
@DisplayName("默认壁纸:schId 已是平台层(0)→ 不二次自查,直接返回该层值/null")
void wallpaperPlatformLayerNoDoubleFallback() {
when(schSettingsRepository.findFirstBySchId(0)).thenReturn(Optional.empty());
ISchSettingsService.LayerValue hit = service.readWallpaperWithFallback(0);
assertNull(hit.getValue());
assertEquals(0, hit.getHitSchId());
Mockito.verify(schSettingsRepository, Mockito.times(1)).findFirstBySchId(Mockito.any());
}
@Test
@DisplayName("LOGO 文字:本校空 → 平台回落(级联读同构回归)")
void logoTextFallsToPlatform() {
when(schSettingsRepository.findFirstBySchId(3)).thenReturn(Optional.empty());
when(schSettingsRepository.findFirstBySchId(0))
.thenReturn(Optional.of(row(0, "云海教育平台", null)));
ISchSettingsService.LayerValue hit = service.readLogoTextWithFallback(3);
assertEquals("云海教育平台", hit.getValue());
assertEquals(0, hit.getHitSchId());
}
}
@@ -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"));
}
}
@@ -0,0 +1,110 @@
package com.yunhai.userhome.utils;
import com.yunhai.platform.utils.exception.DException;
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.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
* ThemeCodec 单测(2026-10-08 下沉共享;2026-10-09 JSON 化行为冻结):
* 存储形态 = 规范化 ThemeState JSON 串(固定字段序);旧紧凑四段仅读侧兼容自动转 JSON;
* decode 宽松(脏值一律 null 视同未配置)、encode 从严(非法抛 DException)、往返一致。
*/
@DisplayName("ThemeCodec - JSON 编解码与紧凑兼容")
class ThemeCodecTest {
private static final String TEAL_JSON =
"{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}";
@Test
@DisplayName("decode:JSON 输入 → 规范化 JSON(字段序固定、布尔形态归一)")
void decodeJson() {
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(TEAL_JSON));
// 字段乱序/紧凑布尔(0/1)也归一为 false/true 规范形态
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(
"{\"gradOn\":true,\"theme\":\"teal\",\"sideFollow\":true,\"sideDeep\":0}"));
assertNull(ThemeCodec.decodeThemeState("{\"theme\":\"teal\"}")); // 字段不齐
assertNull(ThemeCodec.decodeThemeState("{\"theme\":\"red\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}"));
}
@Test
@DisplayName("decode:旧紧凑四段兼容 → 自动转 JSON(迁移期双读)")
void decodeLegacyCompact() {
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState("teal|0|1|1"));
assertEquals("{\"theme\":\"ocean\",\"sideDeep\":true,\"gradOn\":false,\"sideFollow\":false}",
ThemeCodec.decodeThemeState("ocean|1|0|0"));
assertEquals(TEAL_JSON, ThemeCodec.decodeThemeState(" teal|0|1|1 "));
}
@Test
@DisplayName("decode:脏值一律 null(旧档名/段数不对/开关非法/非 0 1/坏 JSON)")
void decodeDirty() {
assertNull(ThemeCodec.decodeThemeState(null));
assertNull(ThemeCodec.decodeThemeState(""));
assertNull(ThemeCodec.decodeThemeState("default-theme")); // findBySchId 兜底遗留值
assertNull(ThemeCodec.decodeThemeState("red|0|1|1")); // 非法档名
assertNull(ThemeCodec.decodeThemeState("teal|0|1")); // 段数不足
assertNull(ThemeCodec.decodeThemeState("teal|0|1|1|1")); // 段数超出
assertNull(ThemeCodec.decodeThemeState("teal|2|1|1")); // 开关非 0/1
assertNull(ThemeCodec.decodeThemeState("teal|true|1|1")); // 开关非单字符
assertNull(ThemeCodec.decodeThemeState("{not json")); // 坏 JSON
assertNull(ThemeCodec.decodeThemeState("[1,2]")); // 非对象
}
@Test
@DisplayName("encode:校验从严 + 产出规范化 JSON(不再产紧凑);紧凑入参抛 DException")
void encode() {
assertEquals(TEAL_JSON, ThemeCodec.encodeThemeState(
"{\"theme\":\"teal\",\"sideDeep\":false,\"gradOn\":true,\"sideFollow\":true}"));
// 0/1 布尔形态归一
assertEquals(TEAL_JSON, ThemeCodec.encodeThemeState(
"{\"theme\":\"teal\",\"sideDeep\":0,\"gradOn\":1,\"sideFollow\":1}"));
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(null));
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(""));
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("teal|0|1|1")); // 紧凑不再可写
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState("{\"theme\":\"red\"}"));
assertThrows(DException.class, () -> ThemeCodec.encodeThemeState(
"{\"theme\":\"teal\",\"sideDeep\":false}"));
}
@Test
@DisplayName("encode→decode 往返一致(合法域内,含紧凑读侧转 JSON 闭环)")
void roundTrip() {
for (String theme : new String[]{"blue", "ocean", "ink", "violet", "teal"}) {
for (int d = 0; d <= 1; d++) for (int g = 0; g <= 1; g++) for (int f = 0; f <= 1; f++) {
String compact = theme + "|" + d + "|" + g + "|" + f;
String json = ThemeCodec.decodeThemeState(compact);
assertEquals(json, ThemeCodec.encodeThemeState(json), "JSON 往返自洽");
assertEquals(json, ThemeCodec.decodeThemeState(json), "encode 产物可再读");
}
}
}
@Test
@DisplayName("toJsonNode:JSON 串 → 节点(oapi 对象出参用);坏串 null")
void toJsonNode() {
assertEquals("teal", ThemeCodec.toJsonNode(TEAL_JSON).get("theme").asText());
assertTrue(ThemeCodec.toJsonNode(TEAL_JSON).get("gradOn").asBoolean());
assertNull(ThemeCodec.toJsonNode("{bad"));
assertNull(ThemeCodec.toJsonNode(null));
}
@Test
@DisplayName("isThemeKey / isAvatarStyle")
void validators() {
assertTrue(ThemeCodec.isThemeKey("blue"));
assertFalse(ThemeCodec.isThemeKey("red"));
assertFalse(ThemeCodec.isThemeKey(null));
assertTrue(ThemeCodec.isAvatarStyle("text"));
assertTrue(ThemeCodec.isAvatarStyle("silhouette"));
assertFalse(ThemeCodec.isAvatarStyle("circle"));
assertFalse(ThemeCodec.isAvatarStyle(null));
assertNotEquals(0, ThemeCodec.THEME_KEYS.length);
}
}