Compare commits

..
49 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
chenxing 8492fba06b fix(msgcenter): 代理出站迁 YschOpenCall,修复目标 ctx 签名不剥导致的 UserMsg 401
build / build-push (push) Successful in 58s
160 事故:MsgCenterProxyService 走共享 IDHttp 出站,AppSignInterceptor 只剥本应用
base-path(/userbase),打 usermsg-service:10009/usermsg 时签名路径多 /usermsg 前缀,
UserMsg AppAuthFilter 剥 ctx 后验签不一致 → 401 → 列表/未读/WS 票据全部静默降级空。

修复:改 YschOpenCall 泛化直调(Tier 0),usermsg-base-url 解析为 TargetSpec
(host + /usermsg ctx),per-target 剥离后签名与服务端对齐;base-url 未配时回落
服务目录 usermsg。新增回归断言:TargetSpec.basePath==/usermsg 且调用 path 为
ctx 裸路径。测试 16/16 绿。
2026-09-20 00:48:05 +08:00
chenxing 5f5ac3db71 feat(sdk+msgcenter): 对齐 SDK 重构包名(openapi.client→base)+ 消息中心代理后端(09-16/09-19 遗漏提交)
build / build-push (push) Successful in 1m34s
- import 全面切换 com.yunhai.platform.openapi.base.*(对齐 Nexus -57 SDK)
- MsgCenter 代理三件(Properties/Controller/ProxyService)+ 测试 11/11(09-19 消息中心二期本地完成态)
- Dockerfile 已在 563edb5 去掉写死 profile;本提交补齐源码侧遗漏
2026-09-19 13:08:23 +08:00
chenxing ed8bbcdf45 ci: retrigger(SDK -57 已发 Nexus)
build / build-push (push) Failing after 1m10s
2026-09-19 13:01:07 +08:00
chenxing 563edb5232 fix(docker): 去掉 ENTRYPOINT 写死的 --spring.profiles.active 与镜像内烤配置
build / build-push (push) Failing after 10s
命令行参数以更高优先级压掉 env SPRING_PROFILES_ACTIVE=platform,prod(平台公共配置层
加载失效,160 实测 profile 只剩 prod);配置改由平台 env + /app/config/ 挂载供给。
2026-09-19 12:53:12 +08:00
chenxing 6aa55bbc44 chore: env-config 迁移后重建镜像(空提交触发 CI)
build / build-push (push) Successful in 1m43s
2026-09-09 17:50:10 +08:00
chenxing 59e8197d22 健康检查,数据埋点
build / build-push (push) Successful in 1m11s
2026-09-03 11:50:22 +08:00
chenxing ac00581417 ci
build / build-push (push) Successful in 1m22s
2026-09-02 11:32:22 +08:00
chenxing 65a17fb881 新增上传头像
build / build-push (push) Successful in 1m25s
2026-09-02 10:44:55 +08:00
chenxing 759b120f2a logo,壁纸修复,加入小应用管理,样式待调整
build / build-push (push) Successful in 2m11s
2026-08-28 14:24:46 +08:00
chenxing 567c8b7786 logo,壁纸修复
build / build-push (push) Successful in 1m36s
2026-08-28 10:28:28 +08:00
chenxing e7ca6dd846 logo,壁纸修复
build / build-push (push) Failing after 40s
2026-08-28 10:23:36 +08:00
chenxing a3a2cd0cf0 桌面修订上线
build / build-push (push) Successful in 1m35s
2026-08-27 21:46:29 +08:00
chenxing ea3d5ed121 json序列化修复
build / build-push (push) Successful in 33s
2026-08-26 19:08:01 +08:00
chenxing d9ec243926 ci
build / build-push (push) Successful in 2m10s
2026-08-26 17:37:42 +08:00
chenxing 4e6dd3e52a add application-prod.yml for docker build
build / build-push (push) Successful in 1m31s
2026-08-24 20:53:48 +08:00
83 changed files with 7467 additions and 257 deletions
+18 -9
View File
@@ -25,7 +25,10 @@ jobs:
options: --network infra-infra-net
steps:
- name: 安装 git / curl(alpine)
run: apk add --no-cache git curl
run: |
# 统一走 aliyun 源:dl-cdn 时段性被拦会卡满 60s×2(2026-10-20 事故)
sed -i 's#dl-cdn.alpinelinux.org#mirrors.aliyun.com#g' /etc/apk/repositories
apk add --no-cache git curl
# 内网 Gitea 无法访问 GitHub 拉 actions/checkout,手动 clone 本仓库
- name: 拉取源码(内网无 GitHub,手动 clone 本仓库)
@@ -55,18 +58,24 @@ jobs:
- name: 构建 jar(maven 容器,复用宿主 m2 缓存)
run: |
set -e
docker rm -f mavenbuild 2>/dev/null || true
docker run -d --name mavenbuild \
# 构建容器名必须全局唯一:runner 挂的是宿主 docker.sock,容器名在整个 daemon 上共享
# 命名空间。固定名会在多仓/多条 workflow 并发时互相 docker rm -f 掉对方正在构建的
# 容器,表现为无编译报错的 exit 137(SIGKILL,2026-10-21 实测复现)。
# GITHUB_RUN_ID 实例级唯一,再拼时间戳兜底。
BUILD_CONTAINER="mavenbuild-${GITHUB_RUN_ID:-0}-$(date +%s)"
echo "BUILD_CONTAINER=${BUILD_CONTAINER}"
docker rm -f "$BUILD_CONTAINER" 2>/dev/null || true
docker run -d --name "$BUILD_CONTAINER" \
--network infra-infra-net \
-v /opt/docker/projman-m2:/root/.m2 \
-w /build maven:3.6.3-jdk-8 sleep 3600
docker cp "${PWD}/." mavenbuild:/build/
docker exec mavenbuild sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec mavenbuild sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec mavenbuild sh -c 'cd /build/target && cp *.jar app.jar'
docker cp "${PWD}/." "$BUILD_CONTAINER":/build/
docker exec "$BUILD_CONTAINER" sh -c "printf '%s' '<settings><profiles><profile><id>nexus-repo</id><repositories><repository><id>nexus-internal</id><url>http://nexus:8081/repository/maven-public/</url><releases><enabled>true</enabled></releases><snapshots><enabled>true</enabled><updatePolicy>always</updatePolicy></snapshots></repository></repositories></profile></profiles><activeProfiles><activeProfile>nexus-repo</activeProfile></activeProfiles></settings>' > /root/.m2/settings.xml"
docker exec "$BUILD_CONTAINER" sh -c 'cd /build && mvn -B -DskipTests clean package'
docker exec "$BUILD_CONTAINER" sh -c 'cd /build/target && cp *.jar app.jar'
mkdir -p "${PWD}/target"
docker cp mavenbuild:/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f mavenbuild
docker cp "$BUILD_CONTAINER":/build/target/app.jar "${PWD}/target/app.jar"
docker rm -f "$BUILD_CONTAINER"
- name: 构建并推送镜像
run: |
+2 -2
View File
@@ -15,9 +15,9 @@ WORKDIR /app
COPY target/app.jar /app/app.jar
# 镜像内 application-prod.yml 仅作「nacos 不可达时的兜底默认」;
# 正常启动由 bootstrap.yml 从 nacos 拉取线上配置(dataId=userhome.yaml)后覆盖。
COPY docker/application-prod.yml /app/config/application-prod.yml
# 配置由平台供给:SPRING_PROFILES_ACTIVE env + /app/config/ 挂载(应用档案 + 平台公共配置层)
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
RUN mkdir -p /data/upload
EXPOSE 10000
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar --spring.profiles.active=prod"]
ENTRYPOINT ["sh", "-c", "java ${JAVA_OPTS:--Xms512m -Xmx1024m} -Dfile.encoding=UTF-8 -jar /app/app.jar"]
+59
View File
@@ -0,0 +1,59 @@
# UserHome 生产配置(线上以 nacos 的 userhome.yaml 为准,本文件是 nacos 不可达时的兜底)
server:
port: 10000
servlet:
# 对外经 caddy 暴露为 http://home.yunhaixingtong.com/userhome
context-path: /userhome
spring:
jackson:
time-zone: GMT+8
date-format: yyyy-MM-dd HH:mm:ss
servlet:
multipart:
max-file-size: 50MB
max-request-size: 200MB
jpa:
database: MySQL
show-sql: false
hibernate:
ddl-auto: update
database-platform: org.hibernate.dialect.MySQL5InnoDBDialect
properties:
hibernate:
jdbc:
time_zone: Asia/Shanghai
open-in-view: false
# 单点登录 / 应用间签名鉴权(基础平台 yh-auth / yh-app-auth)
yh:
auth:
enabled: true
sso:
server-url-prefix: http://sso.yunhaixingtong.com/cas/
client-host-url: http://home.yunhaixingtong.com/userhome
allow-headers: all
allow-origins: all
url-pattern: /api/*
mobi-api-pattern: /mobiapi/*
app-auth:
enabled: true
app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86
open:
# 四域服务目录零配置;身份密钥唯一来源 = yh.app-auth
base-host: http://base.yunhaixingtong.com
base-path: /userbase
# 上传文件落容器卷,compose 里挂到宿主机 ./upload-data
FILE_PATH: /data/upload
FILE_WEB_PATH: http://home.yunhaixingtong.com/userhome/upload/
UPLOAD_WEB_PATH_HTTP: http://home.yunhaixingtong.com/userhome/upload/file/
logging:
level:
root: INFO
com.yunhai.userhome: info
file:
path: /app/logs
+18 -14
View File
@@ -55,6 +55,20 @@
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-BEGIN] 临时埋点引入,但同时也是生产隐患修复:
Dockerfile 一直在探 /actuator/health 而 pom 从未引 actuator(方案 §3.3、事实 #7),
探活 404 隐患原本就该修。压测结束后建议保留本段 actuator(健康探活),
仅按《埋点移除清单.md》评估是否移除 prometheus registry。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<!-- [LOADTEST-METRICS-END] -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
@@ -78,22 +92,12 @@
<version>3.2.0</version>
</dependency>
<!-- 基础平台架包:CAS 认证 + 应用间签名 + 统一响应(经 BOM 传递 yh-openapi-client 等) -->
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
actuator 健康探活,一行带齐(模块构成见 BasePlatform/后端项目知识库.md)。
约定:标准业务服务一律引聚合 starter;平台能力提供方(如 UserBase)按需单引。 -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-app-auth-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-openapi-spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-api-std</artifactId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- Nacos 配置中心(替代 Spring Cloud Config)。Spring Cloud Alibaba 2021.0.5 适配 Spring Boot 2.7 -->
@@ -0,0 +1,75 @@
package com.yunhai.userhome.config;
import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonToken;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.List;
/**
* Jackson 全局配置:统一 java.time.LocalDateTime 的反序列化格式。
*
* <p>背景:UserBase 跨仓接口(如 /oapi/app/list)返回的时间是 "yyyy-MM-dd HH:mm:ss"(空格分隔),
* 而 Jackson 默认的 LocalDateTimeDeserializer 只接受 ISO 格式("yyyy-MM-dd'T'HH:mm:ss"),
* 导致 OpenAPI 客户端反序列化 BoaApp.createTime 时抛 DateTimeParseException,
* 消息中心(getMessageByUserId)等接口因此报"内部服务错误"。
*
* <p>yh-openapi 的装配(YschCallAutoConfiguration.yschOpenCall)经 ObjectProvider 取容器内
* ObjectMapper,本工程定义该 bean 后即被 OpenAPI 客户端(core.YschOpenCall,2026-09-16 SDK
* 重构后的唯一出站公共入口)采用;同时它也是 Spring MVC 使用的全局 mapper,因此同样保留
* spring.jackson.* 配置(GMT+8 时区、java.util.Date 的 yyyy-MM-dd HH:mm:ss 格式)。
*/
@Configuration
public class JacksonConfig {
/** 兼容的 LocalDateTime 字符串格式,按优先级依次尝试:先 UserBase 的空格格式,再 ISO 格式。 */
private static final List<DateTimeFormatter> LOCAL_DATE_TIME_FORMATTERS = Arrays.asList(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
DateTimeFormatter.ISO_LOCAL_DATE_TIME);
@Bean
@Primary
public ObjectMapper objectMapper(Jackson2ObjectMapperBuilder builder) {
SimpleModule module = new SimpleModule();
module.addDeserializer(LocalDateTime.class, new FlexibleLocalDateTimeDeserializer());
return builder.modulesToInstall(module).build();
}
/**
* 兼容 "yyyy-MM-dd HH:mm:ss" 与 ISO 格式的 LocalDateTime 反序列化器;
* 字符串以外的输入(如数字时间戳)沿用 Jackson 默认处理。
*/
private static class FlexibleLocalDateTimeDeserializer extends LocalDateTimeDeserializer {
private FlexibleLocalDateTimeDeserializer() {
super(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
}
@Override
public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
if (p.hasToken(JsonToken.VALUE_STRING)) {
String text = p.getText();
for (DateTimeFormatter formatter : LOCAL_DATE_TIME_FORMATTERS) {
try {
return LocalDateTime.parse(text, formatter);
} catch (DateTimeParseException ignored) {
// 继续尝试下一种格式
}
}
}
return super.deserialize(p, ctxt);
}
}
}
@@ -0,0 +1,39 @@
package com.yunhai.userhome.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* 消息中心代理配置(§3.5 最小消息接入,修 BP-R3-1 审批人待办断头路):
* UserHome 经签名代理调 UserMsg /oapi/msg/**(appKey=本应用 100001,UserMsg trusted-apps 已含),
* jumpPath 是裸相对路径且各应用落地页均为 hash 路由——代理按应用注册表(UserBase by-code/by-key
* 取 pc_url)拼绝对 jumpUrl;手工基址表 jump-bases 已退役(C2)。
*/
@Data
@Component
@ConfigurationProperties(prefix = "userhome.msgcenter")
public class MsgCenterProperties {
/** UserMsg 服务基址;空 = 未接入(代理降级返回空,前端铃铛维持原表现) */
private String usermsgBaseUrl = "";
/**
* flow-center 服务基址(卡片直批 B3 出站,/oapi/flow/**);空 = 走服务目录
* flow-center(PlatformService 枚举兜底,key 与部署名对齐)。不可达时直批降级提示,不阻塞消息卡片。
*/
private String flowCenterBaseUrl = "";
/**
* 浏览器可达的 UserMsg WS 端点(实时推送握手地址);空 = 不下发 WS 地址,
* 前端维持"打开页面拉一次 + 操作后手动刷新"的既有表现。
* 生产经网关暴露,如 wss://msg.yunhaixingtong.com/usermsg/ws/msg。
*/
private String usermsgWsUrl = "";
/** appKey → 应用显示名(消息行/弹层标签补全,如 100617 → 通知公告);未配置回退 msgType 文案 */
private Map<String, String> appNames = 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);
}
}
}
@@ -38,5 +38,13 @@ public class WebMvcConfig implements WebMvcConfigurer {
// wallpaper 子目录:/upload/wallpaper/** → ${FILE_PATH}/wallpaper/
registry.addResourceHandler("/upload/wallpaper/**")
.addResourceLocations("file:" + basePath + "wallpaper/");
// logo 子目录:/upload/logo/** → ${FILE_PATH}/logo/
registry.addResourceHandler("/upload/logo/**")
.addResourceLocations("file:" + basePath + "logo/");
// tempUpload 子目录(应用模板):/upload/tempUpload/** → ${FILE_PATH}/tempUpload/
registry.addResourceHandler("/upload/tempUpload/**")
.addResourceLocations("file:" + basePath + "tempUpload/");
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -16,7 +16,7 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.*;
/**
* 菜单栏控制器(对齐旧 UserHome contorller.api.MenuBarController) 。
* 菜单栏控制器(对齐旧 UserHome contorller.api.MenuBarController) 。
*/
@RestController
@RequestMapping(Config.API_PATH)
@@ -64,6 +64,7 @@ public class MenuBarController {
map.put("openType", model.getLinkOpenType());
result.add(map);
}
return result;
}
@@ -0,0 +1,113 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.model.BoaUser;
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 lombok.RequiredArgsConstructor;
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 java.util.Collections;
import java.util.Map;
/**
* 消息中心代理接口(§3.5 最小消息接入,BP-R3-1):前端铃铛/消息列表改经本控制器
* 签名代理 UserMsg /oapi/msg/**(前端不直连 UserMsg)。旧 {@code /getMessageByUserId}
* 等本地 user_message 表链路保留不动(后续消息中心专项清理)。
*/
@RestController
@RequestMapping(value = Config.API_PATH)
@RequiredArgsConstructor
public class MessageCenterController {
private final MsgCenterProxyService proxy;
private final MsgApprovalProxyService approvalProxy;
/**
* 会话主学校(旧口径):<b>只</b>供待办角标(todo)与卡片直批(flow-center 办理人学校)使用——
* 这两条链路口径不变(方案 §8)。<b>消息读取一律不得用它</b>(R2:跨校切换会错),
* 必须用 {@link MsgCenterProxyService#resolveReadSchId}(当前激活身份学校)。
*/
private Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
}
if (user.getBureau() != null) {
return user.getBureau().getId();
}
return 0;
}
/** 消息读取口径 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")
public Map<String, Object> list(BoaUser authUserWithDept,
@RequestParam(value = "page", required = false) Integer page,
@RequestParam(value = "count", required = false) Integer count,
@RequestParam(value = "onlyUnread", required = false) Boolean 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")
public Map<String, Object> summary(BoaUser 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));
}
/**
* WS 实时推送握手地址(UserMsg 一次性票据 + 浏览器可达端点)。
* 未配置 WS / UserMsg 不可达 → url=null,前端维持"手动刷新"兜底表现。
*/
@GetMapping("msgCenter/wsTicket")
public Map<String, Object> wsTicket(BoaUser authUserWithDept) {
Map<String, Object> out = new java.util.HashMap<>();
out.put("url", proxy.createWsTicket(authUserWithDept.getId()));
return out;
}
/** 标记单条已读(按登录人代理,UserMsg 侧带归属人防伪;schId 只用于缓存失效键与 readState 口径) */
@PostMapping("msgCenter/read")
public void read(BoaUser authUserWithDept, @RequestParam("msgId") Long msgId) {
proxy.read(authUserWithDept.getId(), Collections.singletonList(msgId), msgSchId(authUserWithDept));
}
/** 全部已读(按当前身份学校限定:只标本校+平台未读,他校未读不受影响,D6) */
@PostMapping("msgCenter/readAll")
public void readAll(BoaUser authUserWithDept) {
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,11 +1,12 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.view.RedirectView;
@@ -25,6 +26,9 @@ import java.util.Map;
@RequestMapping(value = Config.API_PATH)
public class MiscController {
@Autowired
private OpenApiUserClient openApiUserClient;
/**
* 修改用户密码(对齐旧 UserController.updateUserPassword)。
* 前端调用:BaseApi.updatePassword → POST /api/updateUserPassword
@@ -40,7 +44,7 @@ public class MiscController {
throw new BizException(ErrorCode.CUSTOM, "新密码不得与旧密码相同!");
}
try {
YschBaseOpenApi.updateUserPassword(authUserWithDept.getId(), oldPassword, password);
openApiUserClient.updateUserPassword(authUserWithDept.getId(), oldPassword, password);
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
}
@@ -1,9 +1,14 @@
package com.yunhai.userhome.controller.api;
import com.yunhai.platform.utils.exception.DException;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserCurrentRole;
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.web.bind.annotation.GetMapping;
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 java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
/**
* 学校设置接口(CAS 登录态,路径 {@code /{platform}/}),对齐旧 UserHome SchSettingsController。
* <p>getSchSettings 实际跨仓调用 schConfig(见 SchSettingsServiceImpl);
* saveSchTheme/saveSchLogo(P1)本期写入本地 sch_settings 表(跨仓回写可后续对接)。 </p>
* <p>getSchSettings 查本地 sch_settings 表(不再依赖跨仓 schConfig);
* 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
@RequestMapping(value = Config.API_PATH)
@@ -26,20 +48,175 @@ public class SchSettingsController {
@Autowired
private ISchSettingsService schSettingsService;
private Integer resolveSchId(BoaUser user) {
if (user.getSchool() != null) {
return user.getSchool().getId();
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
/** 主题档写权限:超管或学校管理员(先例: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();
}
/** 学校默认头像样式(默认头像轮 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);
}
return 0;
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")
public void saveSchTheme(BoaUser authUserWithDept, String themeName, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept);
schId = schSettingsService.resolveSchId(authUserWithDept);
}
SchSettings schSettings = new SchSettings();
Optional<SchSettings> model = schSettingsService.findBySchId(schId);
@@ -56,7 +233,7 @@ public class SchSettingsController {
@PostMapping("saveSchLogo")
public void saveSchLogo(BoaUser authUserWithDept, String logoUrl, @RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept);
schId = schSettingsService.resolveSchId(authUserWithDept);
}
SchSettings schSettings = newSchSettingsOrLoad(schId, authUserWithDept);
schSettings.setLogoUrl(logoUrl);
@@ -68,7 +245,7 @@ public class SchSettingsController {
public Optional<SchSettings> getSchSettings(BoaUser authUserWithDept,
@RequestParam(name = "schId", defaultValue = "0") Integer schId) {
if (schId == null || schId == 0) {
schId = resolveSchId(authUserWithDept);
schId = schSettingsService.resolveSchId(authUserWithDept);
}
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.userhome.config.Config;
import com.yunhai.userhome.entity.UsedApp;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.model.SetType;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService;
import com.yunhai.userhome.service.UsedAppService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -36,6 +38,9 @@ public class UsedAppController {
@Autowired
private AppRemoteService appRemoteService;
@Autowired
private AppIconKeyService appIconKeyService;
/**
* 取当前角色生效的常用应用(BoaApp 详情,按 appIds 顺序)。
*
@@ -112,7 +117,7 @@ public class UsedAppController {
usedAppService.saveUserId(usedApp);
}
/** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url),在全集中查找。 */
/** 按 appIds(逗号分隔)取应用精简映射(id/name/icon/url/iconKey/iconColor),在全集中查找。 */
@GetMapping("getAppByIds")
public List<Map<String, Object>> getAppByIds(BoaUser authUserWithDept, String appIds) {
List<Map<String, Object>> list = new ArrayList<>();
@@ -132,6 +137,10 @@ public class UsedAppController {
m.put("name", app.getName());
m.put("icon", app.getIcon());
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);
});
} catch (NumberFormatException ignore) {
@@ -1,16 +1,23 @@
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.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
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.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -19,7 +26,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 用户首页接口(CAS 登录态,路径 {@code /{platform}/user/})。
@@ -29,11 +38,25 @@ import java.util.List;
@RequestMapping(value = Config.API_PATH + "/user/")
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
private AppRemoteService appRemoteService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
private OpenApiUserClient openApiUserClient;
@Autowired
private YschOpenCall openCall;
@Autowired
private AppIconKeyService appIconKeyService;
@Autowired
private ObjectMapper objectMapper;
/** 当前登录用户信息(CAS 注入)。 */
@@ -55,19 +78,56 @@ public class UserController {
* 当前用户可访问的应用全集(跨仓 UserBase 实时拉取,按应用类型分组)。
* 前端据此:渲染分类 tab(含「全部」)+ 图标网格;常用区由 getUsedApp 独立提供。
*
* @param schId 兼容旧前端入参(yh-platform getUserApp 当前按 userId 全局权限过滤,schId 暂不参与)
* @param schId 学校上下文:透传 UserBase 按该校单点开通集合收窄(为空则由服务端按当前身份/主归属学校兜底)
* @param userId 指定用户(缺省用登录用户)
*/
@GetMapping("GetUserApp")
public List<BoaAppType> getUserApp(BoaUser authUserWithDept, Integer schId,
public JsonNode getUserApp(BoaUser authUserWithDept, Integer schId,
@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")
public BoaApp getAppInfoById(BoaUser authUserWithDept, @RequestParam(name = "id", required = false) Integer id) {
return appRemoteService.getAppById(authUserWithDept, id).orElse(null);
public BoaApp getAppInfoById(BoaUser authUserWithDept, Integer schId,
@RequestParam(name = "id", required = false) Integer id) {
return appRemoteService.getAppById(authUserWithDept, id, schId).orElse(null);
}
/** 当前用户角色身份列表(跨仓 UserBase,含 学校/角色/子节点)。 */
@@ -76,23 +136,60 @@ public class UserController {
return YschBaseOpenApi.getUserRoleList(authUserWithDept.getId());
}
/** 当前用户激活的角色(本地 user_current_role,可能为空)。 */
/** 当前用户激活的身份(UserBase 单一权威源;未切换时兜底主身份,无身份为 null)。 */
@GetMapping("findUserRole")
public UserCurrentRole findUserRole(BoaUser authUserWithDept) {
return userCurrentRoleService.findById(authUserWithDept.getId()).orElse(null);
public BoaRoleInfo findUserRole(BoaUser authUserWithDept) {
return YschBaseOpenApi.getCurrentIdentity(authUserWithDept.getId());
}
/** 保存当前激活角色(校验所选 学校+角色 属于用户角色列表,防越权)。 */
/**
* 保存当前激活角色:userId 只取 CAS 会话(无视请求体,防切他人),
* 目标 (roleId, schId) 是否属该用户由 UserBase 侧 sys_user_role_rel 三元组校验。
*/
@PostMapping("savUserRole")
public void savUserRole(BoaUser authUserWithDept, @RequestBody UserCurrentRole roleInfo) {
List<BoaRoleInfo> roleList = YschBaseOpenApi.getUserRoleList(authUserWithDept.getId());
boolean exists = roleList.stream().anyMatch(r ->
r.getRoleId() != null && r.getRoleId().equals(roleInfo.getRoleId())
&& r.getSchId() != null && r.getSchId().equals(roleInfo.getSchId()));
if (!exists) {
throw new BizException(ErrorCode.FORBIDDEN, "非法操作,没有权限");
public void savUserRole(BoaUser authUserWithDept, @RequestBody BoaRoleInfo roleInfo) {
if (roleInfo.getRoleId() == null || roleInfo.getSchId() == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "角色或学校不能为空");
}
roleInfo.setUserId(authUserWithDept.getId());
userCurrentRoleService.save(roleInfo);
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 包信封
}
}
@@ -1,29 +1,33 @@
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.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
import com.yunhai.platform.utils.model.BoaGroup;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.config.Config;
import com.yunhai.userhome.entity.*;
import com.yunhai.userhome.enums.DesktopAppType;
import com.yunhai.userhome.enums.LightAppGroup;
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.IUserSettingsService;
import com.yunhai.userhome.service.IWidgetCatalogService;
import com.yunhai.userhome.service.UsedAppService;
import com.yunhai.userhome.service.WidgetLinkResolver;
import com.yunhai.userhome.utils.EnumUtil;
import com.yunhai.userhome.utils.FileUtil;
import com.yunhai.userhome.utils.OtherUtil;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
@@ -32,12 +36,6 @@ import java.util.stream.Collectors;
/**
* 用户设置控制器(对齐旧 UserHome contorller.api.UserSettingsController)。
*
* <p>待确认接口(已写入待确认清单):
* <ul>
* <li>getUserSettings 中 bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认</li>
* <li>GetSchSettingsWallpaper 依赖 SCH_CONF_URL —— 需确认环境地址</li>
* </ul>
*/
@RestController
@RequestMapping(Config.API_PATH)
@@ -49,31 +47,62 @@ public class UserSettingsController {
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Value("${SCH_CONF_URL:}")
private String SCH_CONF_URL;
@Autowired
private IUserSettingsService userSettingsService;
@Autowired
private ISchSettingsService schSettingsService;
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
@Autowired
private UsedAppService usedAppService;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private WidgetLinkResolver widgetLinkResolver;
@Autowired
private OpenApiUserClient openApiUserClient;
@Autowired
private AppIconKeyService appIconKeyService;
@Autowired
private IWidgetCatalogService widgetCatalogService;
// ==================== 壁纸 ====================
/**
* 上传壁纸(Header 弹窗与系统设置页共用)。
*
* <p>超管:可选 schId 参数——传 schId&gt;0 归属该校(type=1),不传/0 维持系统级(type=0);
* 校管理员:忽略入参固定本校(type=1);普通用户维持个人壁纸(type=2)。</p>
*/
@PostMapping(value = "wallpaperUpload", produces = "application/json")
public void wallpaperUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
public void wallpaperUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file,
@RequestParam(value = "schId", required = false) Integer schId) {
checkFile(file);
String uploadUrl = this.upload(file, "wallpaper");
UserCurrentRole currentRole = userCurrentRoleService.findById(authUserWithDept.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
Integer type = authUserWithDept.getType() == 1 ? 0 : OtherUtil.isAdminById(currentRole.getRoleId()) ? 1 : 2;
Integer type;
Integer targetSchId;
if (authUserWithDept.getType() == 1) {
if (schId != null && schId > 0) {
type = 1;
targetSchId = schId;
} else {
// 超管上传的系统壁纸 schId 强制 0,避免切学校身份后产生孤儿数据
type = 0;
targetSchId = 0;
}
} else {
type = OtherUtil.isAdminById(currentRole.getRoleId()) ? 1 : 2;
targetSchId = currentRole.getSchId();
}
Wallpaper wallpaper = new Wallpaper();
wallpaper.setSchId(currentRole.getSchId());
wallpaper.setSchId(targetSchId);
wallpaper.setWallpaperUrl(uploadUrl);
wallpaper.setUserId(authUserWithDept.getId());
wallpaper.setType(type);
@@ -82,6 +111,7 @@ public class UserSettingsController {
@PostMapping(value = "iconUpload", produces = "application/json")
public Map<String, String> iconUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
checkFile(file);
Map<String, String> result = new HashMap<>();
result.put("url", this.upload(file, "icon"));
return result;
@@ -89,12 +119,20 @@ public class UserSettingsController {
@PostMapping(value = "deleteWallById", produces = "application/json")
public void deleteWallById(BoaUser authUserWithDept, Integer id) {
userSettingsService.deleteWallById(id);
userSettingsService.deleteWallById(id, authUserWithDept);
}
@PostMapping(value = "checkWallpaper", produces = "application/json")
public void checkWallpaper(BoaUser authUserWithDept, @RequestBody Map res) {
String wallpaperUrl = res.get("wallpaperUrl").toString();
// 校验 URL 属于该用户可见壁纸集合(本地 wallpaper 表),防止设置任意字符串
String normalized = normalizeWallpaperUrl(wallpaperUrl);
Set<String> visible = new HashSet<>();
userSettingsService.findWallList(authUserWithDept.getId())
.forEach(w -> visible.add(w.getWallpaperUrl()));
if (!visible.contains(normalized)) {
throw new BizException(ErrorCode.FORBIDDEN, "壁纸不存在或无权使用");
}
userSettingsService.checkWallpaper(authUserWithDept.getId(), wallpaperUrl);
}
@@ -111,18 +149,138 @@ public class UserSettingsController {
map.put("isEdit", model.getIsEdit());
result.add(map);
});
if (authUserWithDept.getType() != 1 && !list.stream().anyMatch(x -> x.getType() == 1)) {
List<String> schList = getSchSettingsWallpaper(schId);
for (String schWall : schList) {
Map map = new HashMap();
map.put("id", null);
map.put("type", 1);
map.put("url", schWall);
map.put("isEdit", false);
result.add(map);
return result;
}
// ==================== Logo ====================
@PostMapping(value = "logoUpload", produces = "application/json")
public void logoUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file,
@RequestParam(value = "schId", required = false) Integer schId) {
// 仅超管(系统默认/指定学校)与校管理员(本校)可上传,普通用户无权设置 Logo
Integer type;
Integer targetSchId;
if (authUserWithDept.getType() == 1) {
if (schId != null && schId > 0) {
type = 1;
targetSchId = schId;
} else {
type = 0;
targetSchId = 0;
}
} else {
UserCurrentRole currentRole = userCurrentRoleService.findById(authUserWithDept.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
if (!OtherUtil.isAdminById(currentRole.getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权设置Logo");
}
type = 1;
targetSchId = currentRole.getSchId();
}
checkFile(file);
String uploadUrl = this.upload(file, "logo");
Logo logo = new Logo();
logo.setType(type);
logo.setSchId(targetSchId);
logo.setLogoUrl(uploadUrl);
logo.setUserId(authUserWithDept.getId());
userSettingsService.saveLogo(logo);
}
@GetMapping(value = "getLogoList")
public List<Map> getLogoList(BoaUser authUserWithDept) {
List<Map> result = new ArrayList<>();
List<Logo> list = userSettingsService.findLogoList(authUserWithDept.getId());
list.forEach(model -> {
Map map = new HashMap();
map.put("id", model.getId());
map.put("url", FILE_WEB_PATH + model.getLogoUrl().replace("/upload", ""));
map.put("type", model.getType());
map.put("isEdit", model.getIsEdit());
result.add(map);
});
return result;
}
@PostMapping(value = "deleteLogoById", produces = "application/json")
public void deleteLogoById(BoaUser authUserWithDept, Integer id) {
userSettingsService.deleteLogoById(id, authUserWithDept);
}
// ==================== 系统设置(学校维度管理视图) ====================
/**
* 机构树(仅超管,403 兜底):YschBaseOpenApi.getGroupTree() → 递归裁剪到学校层
* (对齐原 systemsetting SysController.RecursionGroup:type==1 普通组跳过、type==2 学校组置叶子)。
*/
@GetMapping(value = "getSchTree")
public BoaGroup getSchTree(BoaUser authUserWithDept) {
if (authUserWithDept.getType() != 1) {
throw new BizException(ErrorCode.FORBIDDEN);
}
BoaGroup group = YschBaseOpenApi.getGroupTree();
return recursionGroup(group);
}
/**
* 壁纸管理列表(系统设置页):系统壁纸(type=0) + 目标学校壁纸(type=1),不含个人壁纸(type=2)。
* <p>超管可传任意 schId(不传按 0 即平台默认);校管理员忽略入参固定本校,系统壁纸仅超管可删。</p>
*/
@GetMapping(value = "getSchWallpaperManageList")
public List<Map> getSchWallpaperManageList(BoaUser authUserWithDept, Integer schId) {
List<Map> result = new ArrayList<>();
userSettingsService.findSchWallpaperManageList(authUserWithDept, schId).forEach(model -> {
Map map = new HashMap();
map.put("id", model.getId());
map.put("url", FILE_WEB_PATH + model.getWallpaperUrl().replace("/upload", ""));
map.put("type", model.getType());
map.put("isEdit", model.getIsEdit());
result.add(map);
});
return result;
}
/**
* Logo 管理列表(系统设置页):系统默认(type=0) + 目标学校(type=1)。
* <p>同 getSchWallpaperManageList 权限语义;logo 单张,前端取第一张显示,上传即覆盖。</p>
*/
@GetMapping(value = "getSchLogoManageList")
public List<Map> getSchLogoManageList(BoaUser authUserWithDept, Integer schId) {
List<Map> result = new ArrayList<>();
userSettingsService.findSchLogoManageList(authUserWithDept, schId).forEach(model -> {
Map map = new HashMap();
map.put("id", model.getId());
map.put("url", FILE_WEB_PATH + model.getLogoUrl().replace("/upload", ""));
map.put("type", model.getType());
map.put("isEdit", model.getIsEdit());
result.add(map);
});
return result;
}
/**
* 递归裁剪机构树到学校层(对齐原 systemsetting SysController.RecursionGroup)。
*/
private BoaGroup recursionGroup(BoaGroup group) {
// 组类型:1 普通组 2 学校组 3 年级组 4 班级组 5 区域组 6 学段组 7 教师组 8 学生组
List<BoaGroup> list = new ArrayList<>();
List<BoaGroup> children = group.getChildren();
if (children == null) {
return group;
}
for (BoaGroup boaGroup : children) {
if (boaGroup.getType() == null) continue;
if (boaGroup.getType() == 1) continue;
if (boaGroup.getType() == 2) {
boaGroup.setChildren(null);
list.add(boaGroup);
} else {
BoaGroup model = this.recursionGroup(boaGroup);
list.add(model);
}
}
return result;
group.setChildren(list);
return group;
}
// ==================== 用户设置 ====================
@@ -131,22 +289,37 @@ public class UserSettingsController {
public UserSettings getUserSettings(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
Integer schId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getSchId() : null;
Integer roleId = currentRoleOpt.isPresent() ? currentRoleOpt.get().getRoleId() : null;
UserSettings model = new UserSettings();
Optional<UserSettings> optional = userSettingsService.findUserSettings(authUserWithDept.getId());
// TODO 待确认:bschBaseOpenApi.getPcGrzxPage —— 新框架 YschBaseOpenApi 是否有此方法待确认
// List<BoaApp> appPageList = bschBaseOpenApi.getPcGrzxPage(authUserWithDept.getId(), schId);
List<BoaApp> appPageList = Collections.emptyList();
// 候选池真源 = 自建 widget_catalog(生效启用 + 可见范围命中本校 + 可见角色命中当前角色;name 已按显示名三级链合成)
List<WidgetCatalog> catalogList = widgetCatalogService.findEnabledForSchool(schId, roleId);
if (optional.isPresent()) model = optional.get();
if ((model.getWallpaperUrl() == null || model.getWallpaperUrl().isEmpty()) && schId != null) {
List<String> schList = getSchSettingsWallpaper(schId);
if (schList.size() > 0) model.setWallpaperUrl(schList.get(0));
// 壁纸池一次取全:悬空自愈判定与兜底选择共用
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.put("id", boaApp.getId());
map.put("appId", boaApp.getId());
map.put("name", boaApp.getName());
map.put("url", boaApp.getWebAddr());
map.put("id", catalog.getId());
map.put("appId", catalog.getId());
map.put("name", catalog.getName());
map.put("code", catalog.getCode());
map.put("compType", catalog.getCompType());
// 外跳入口:库里只存「子应用 code + 路由」,绝对地址按应用注册表 pc_url 解析后下发;
// 解析不出(未配置 / 查无 / 注册表不可达)则不下发,前端不渲染入口
String overviewUrl = widgetLinkResolver.resolve(catalog.getLinkAppCode(), catalog.getLinkPath());
if (overviewUrl != null) {
map.put("overviewUrl", overviewUrl);
}
return map;
}).collect(Collectors.toList());
@@ -154,6 +327,41 @@ public class UserSettingsController {
return model;
}
/**
* 兜底壁纸(用户未自选时,壁纸三级默认轮 2026-10-09):本校默认(sch_settings.wallpaper_url,管理员选定)
* → 平台默认(schId=0 行)→ 本地学校壁纸池第一张 → 平台池第一张(历史兜底,两级皆未配置时保持原体验)。
* <p>学校壁纸完全本地管理(wallpaper 表 type=1),不再依赖跨仓 schConfig。
* 管理员选定默认返回前校验其仍在壁纸池中——被删池视为未配置继续下落,避免兜底本身返回死 URL(2026-10-09 白板修复)。</p>
*/
private String resolveFallbackWallpaper(BoaUser authUserWithDept, Integer schId, List<Wallpaper> wallList) {
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) {
// 3. 本地学校壁纸第一张(type=1)
Optional<Wallpaper> schWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 1
&& w.getSchId() != null && w.getSchId().equals(schId))
.min(Comparator.comparing(Wallpaper::getId));
if (schWall.isPresent()) {
return FILE_WEB_PATH + schWall.get().getWallpaperUrl().replace("/upload", "");
}
}
// 4. 超管全局壁纸第一张(type=0)
Optional<Wallpaper> sysWall = wallList.stream()
.filter(w -> w.getType() != null && w.getType() == 0)
.min(Comparator.comparing(Wallpaper::getId));
if (sysWall.isPresent()) {
return FILE_WEB_PATH + sysWall.get().getWallpaperUrl().replace("/upload", "");
}
return null;
}
@GetMapping(value = "getUserDesktopAppList")
public List<Map> getUserDesktopAppList(BoaUser authUserWithDept) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(authUserWithDept.getId());
@@ -205,6 +413,10 @@ public class UserSettingsController {
map.put("type", DesktopAppType.Bsch.getKey());
map.put("linkType", LinkOpenType.Open.getKey());
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) {
Optional<LightApp> optionalLightApp = lightAllList.stream()
.filter(app -> desktopApp.getAppId().equals(app.getId())).findFirst();
@@ -358,6 +570,33 @@ public class UserSettingsController {
userSettingsService.saveDesktopAppList(desktopApps);
}
// ==================== 头像 ====================
/**
* 上传头像:校验后经 OpenAPI 转发至 UserBase 统一存储(压缩并写入 sys_user.profile_picture)。<br>
* 前端调用:UserInfoModal 更换头像 → POST /api/avatarUpload。<br>
* 注意不复用 checkFile(其 5MB 上限被壁纸/图标共用),头像单独限制 20MB(与前端一致,拍照原图普遍偏大)。
*/
@PostMapping(value = "avatarUpload", produces = "application/json")
public Map<String, String> avatarUpload(BoaUser authUserWithDept, @RequestParam("file") MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
}
if (file.getSize() > 20L * 1024 * 1024) {
throw new BizException(ErrorCode.CUSTOM, "图片大小不能超过20MB");
}
String url;
try {
url = openApiUserClient.updateUserProfilePicture(
authUserWithDept.getId(), file.getBytes(), file.getOriginalFilename());
} catch (Exception e) {
throw new BizException(ErrorCode.CUSTOM, e.getMessage());
}
Map<String, String> result = new HashMap<>();
result.put("url", url);
return result;
}
// ==================== 私有方法 ====================
private String upload(@RequestParam("file") MultipartFile file, String pathName) {
@@ -368,34 +607,31 @@ public class UserSettingsController {
}
/**
* 获取学校设置的壁纸集合(对齐旧 UserHome GetSchSettingsWallpaper,fastjson → Jackson)。
* 上传文件前置校验:非空 + 大小上限(5MB)。扩展名白名单在 FileUtil 内校验。
*/
private List<String> getSchSettingsWallpaper(Integer schId) {
if (SCH_CONF_URL == null || SCH_CONF_URL.isEmpty()) return Collections.emptyList();
String apiPath = SCH_CONF_URL + "/oapi/getSchSetting?schId=" + schId;
List<String> result = new ArrayList<>();
try {
String resultStr = restTemplate.getForObject(apiPath, String.class);
if (resultStr == null || resultStr.isEmpty()) return result;
JsonNode root = objectMapper.readTree(resultStr);
JsonNode dataNode = root.get("data");
if (root.has("code") && "0".equals(root.get("code").asText()) && dataNode != null && !dataNode.isNull()) {
JsonNode appBgUrlNode = dataNode.get("appBackgroundUrl");
if (appBgUrlNode == null) appBgUrlNode = dataNode.get("appBgUrl");
String appBgUrlStr = appBgUrlNode != null ? appBgUrlNode.asText("") : "";
if (appBgUrlStr.isEmpty()) return result;
String[] imgUrls = appBgUrlStr.split(",");
for (String imgUrl : imgUrls) {
if (!imgUrl.contains(SCH_CONF_URL))
result.add(SCH_CONF_URL + imgUrl);
else
result.add(imgUrl);
}
}
} catch (Exception e) {
// 跨仓调用失败时返回空列表
private void checkFile(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.CUSTOM, "请选择上传文件");
}
return result;
if (file.getSize() > 5 * 1024 * 1024) {
throw new BizException(ErrorCode.CUSTOM, "图片大小不能超过5MB");
}
}
/**
* 归一化前端回传的壁纸 URL:去掉 FILE_WEB_PATH 前缀,并还原 getUserWallpaperList
* 输出时被 replace("/upload","") 移除的 /upload 前缀,得到与 wall 表存储一致的形式。
*/
private String normalizeWallpaperUrl(String url) {
if (url == null) return null;
String u = url.trim();
if (FILE_WEB_PATH != null && !FILE_WEB_PATH.isEmpty() && u.startsWith(FILE_WEB_PATH)) {
u = u.substring(FILE_WEB_PATH.length());
}
if (u.startsWith("/wallpaper/") || u.startsWith("/icon/") || u.startsWith("/logo/")) {
u = "/upload" + u;
}
return u;
}
/**
@@ -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);
}
}
@@ -0,0 +1,48 @@
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 javax.validation.constraints.NotNull;
/**
* Logo 实体(镜像 Wallpaper 模式:超管设置系统默认,学校设置自身)。
* <p>type:0 系统默认(schId=0) | 1 学校(schId=学校ID);一校一行,type+sch_id 唯一。</p>
*/
@AllArgsConstructor
@NoArgsConstructor
@Data
@Entity
@Table(name = "logo", uniqueConstraints = {@UniqueConstraint(columnNames = {"type", "sch_id"}, name = "uk_type_sch")})
public class Logo {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@NotNull(groups = Update.class)
private Integer id;
/** 0:系统默认 | 1:学校 */
private Integer type;
@Column(name = "sch_id")
private Integer schId;
private Integer userId;
@Column(name = "logo_url")
private String logoUrl;
@Transient
private Boolean isEdit = false;
public @interface Update {}
}
@@ -9,6 +9,7 @@ import javax.persistence.CollectionTable;
import javax.persistence.Column;
import javax.persistence.ElementCollection;
import javax.persistence.Entity;
import javax.persistence.FetchType;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
@@ -51,7 +52,7 @@ public class MenuBar {
private Integer schId;
@ElementCollection
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "menu_bar_role")
@Column(name = "role_ids")
private List<Integer> roleIds = new ArrayList<>();
@@ -13,8 +13,8 @@ import javax.persistence.Table;
/**
* 学校设置(sch_settings 表,字段名/表名对齐旧 UserHome model.SchSettings)。
* <p>注意:旧版 {@code SchSettingsService.getSchSettings} 并不查本地表,而是跨仓调用
* {@code SCH_CONF_URL/oapi/getSchSetting},返回结构经 fastjson 映射为此实体。本工程保持同一行为。</p>
* <p>本工程学校设置完全本地化:saveSchTheme/saveSchLogo 写入本地表,getSchSettings 直接查本地表,
* 不再依赖旧版跨仓 {@code SCH_CONF_URL/oapi/getSchSetting}。</p>
*/
@AllArgsConstructor
@NoArgsConstructor
@@ -30,9 +30,22 @@ public class SchSettings {
@Column(name = "theme_name")
private String themeName;
/** 学校默认头像样式(默认头像轮 2026-09-30):text|silhouette;null=未配置(读侧可空不兜底) */
@Column(name = "avatar_style")
private String avatarStyle;
@Column(name = "logo_url")
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")
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;
}
@@ -1,30 +1,16 @@
package com.yunhai.userhome.entity;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* 用户当前激活角色(会话态,本地表 user_current_role)。
* 对应旧 UserHome 同名单表:一用户一行,记录当前角色 + 学校。
* 用户当前激活角色(纯 DTO,非持久化)。<br>
* 自多角色迁移方案落地后,UserHome 不再持有本地角色存储——数据由 UserBase 单一权威源提供:
* {@code UserCurrentRoleServiceImpl.findById} 经 SDK 调 UserBase {@code /oapi/identity/current}。
* 字段与 {@code BoaRoleInfo}(roleId/roleName/schId/schName/childId/childName)完全一致,保留以稳定既有调用点。
*/
@Data
@Entity
@Table(
name = "user_current_role",
uniqueConstraints = @UniqueConstraint(columnNames = {"userId"}),
indexes = {@Index(columnList = "userId")}
)
public class UserCurrentRole {
@Id
@JsonIgnore
private Integer userId;
private Integer roleId;
@@ -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,22 @@
package com.yunhai.userhome.enums;
import lombok.AllArgsConstructor;
import lombok.Getter;
import lombok.NoArgsConstructor;
/**
* 桌面应用角色枚举(替代原 GetBaseDict 字典接口,前端本地枚举)。
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
public enum RoleType {
SCHOOL_ADMIN(1, "校管理员"),
TEACHER(2, "教师"),
STUDENT(3, "学生"),
;
private Integer key;
private String value;
}
@@ -0,0 +1,143 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;
/**
* [LOADTEST-METRICS] UserHome 指标内存累加器(方案 §3)。<br>
* 按 (uri, method, direction, isLoadTest) 维度 ConcurrentHashMap O(1) 原子累加,
* 不走队列、不做同步 IO;每 60s 快照落库 api_metrics,失败加回不丢数。<br>
* Nacos 可下发 api.metrics.enabled=false 整体停用。<br>
* (本类自带 @EnableScheduling 见 ApiMetricsConfig,UserHome 主类原本未开启定时任务,
* 删除本包后定时能力一并消失,不留痕迹。)
*/
@Slf4j
@Component
public class ApiMetricsCollector {
/** 聚合维度 key(uri×method×direction×压测标记) */
private static final class Dim {
final String uri;
final String method;
final String direction;
final boolean loadTest;
Dim(String uri, String method, String direction, boolean loadTest) {
this.uri = uri;
this.method = method;
this.direction = direction;
this.loadTest = loadTest;
}
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (!(o instanceof Dim)) {
return false;
}
Dim d = (Dim) o;
return loadTest == d.loadTest && uri.equals(d.uri) && method.equals(d.method)
&& direction.equals(d.direction);
}
@Override
public int hashCode() {
return Objects.hash(uri, method, direction, loadTest);
}
}
/** 单维度三指标:次数 / 耗时累计 / 错误数 */
private static final class Counter {
final AtomicLong cnt = new AtomicLong();
final AtomicLong cost = new AtomicLong();
final AtomicLong err = new AtomicLong();
}
private final ConcurrentHashMap<Dim, Counter> counters = new ConcurrentHashMap<>();
@Autowired
private ApiMetricsRepository repository;
/** 埋点总开关(Nacos 可下发紧急关停) */
@Value("${api.metrics.enabled:true}")
private boolean enabled;
/** 入站 Filter / 出站切面调用:一次请求(或一次出站调用)结束时记一笔 */
public void record(String uri, String method, String direction, long costMs, boolean error, boolean loadTest) {
if (!enabled) {
return;
}
Counter c = counters.computeIfAbsent(new Dim(uri, method, direction, loadTest), k -> new Counter());
c.cnt.incrementAndGet();
c.cost.addAndGet(costMs);
if (error) {
c.err.incrementAndGet();
}
}
/** 快照窗口(ms),对齐方案 60s */
@Scheduled(fixedDelayString = "${api.metrics.flush-interval-ms:60000}")
public void flush() {
if (!enabled || counters.isEmpty()) {
return;
}
LocalDateTime sliceTime = LocalDateTime.now();
List<ApiMetricsEntity> rows = new ArrayList<>();
List<Object[]> snapshot = new ArrayList<>(counters.size());
for (ConcurrentHashMap.Entry<Dim, Counter> e : counters.entrySet()) {
long cnt = e.getValue().cnt.getAndSet(0);
long cost = e.getValue().cost.getAndSet(0);
long err = e.getValue().err.getAndSet(0);
if (cnt <= 0) {
continue;
}
snapshot.add(new Object[]{e.getKey(), cnt, cost, err});
ApiMetricsEntity row = new ApiMetricsEntity();
row.setStatTime(sliceTime);
row.setUri(e.getKey().uri);
row.setMethod(e.getKey().method);
row.setDirection(e.getKey().direction);
row.setCnt(cnt);
row.setTotalCost(cost);
row.setErrCnt(err);
row.setIsLoadTest(e.getKey().loadTest ? 1 : 0);
rows.add(row);
}
if (rows.isEmpty()) {
return;
}
try {
repository.saveAll(rows);
} catch (Exception ex) {
// 落库失败:数值加回内存,并入下个窗口,不静默丢数
log.warn("[LOADTEST-METRICS] api_metrics 落库失败,{} 行并入下个窗口: {}", rows.size(), ex.getMessage());
for (Object[] s : snapshot) {
Dim dim = (Dim) s[0];
Counter c = counters.computeIfAbsent(dim, k -> new Counter());
c.cnt.addAndGet((Long) s[1]);
c.cost.addAndGet((Long) s[2]);
c.err.addAndGet((Long) s[3]);
}
}
}
}
@@ -0,0 +1,35 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
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 org.springframework.scheduling.annotation.EnableScheduling;
/**
* [LOADTEST-METRICS] 埋点装配:注册入站指标过滤器 + 开启定时快照。<br>
* @EnableScheduling 只服务于 {@link ApiMetricsCollector#flush()}(UserHome 主类原本未开定时任务,
* 删包后此能力一并消失,主类零改动);过滤器取最前置 order,连 CAS 302 也计入入站口径。
*/
@Configuration
@EnableScheduling
public class ApiMetricsConfig {
@Bean
public FilterRegistrationBean<ApiMetricsFilter> apiMetricsFilter(ApiMetricsCollector collector) {
FilterRegistrationBean<ApiMetricsFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new ApiMetricsFilter(collector));
bean.addUrlPatterns("/*");
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
bean.setName("apiMetricsFilter");
return bean;
}
}
@@ -0,0 +1,152 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import com.yunhai.userhome.config.Config;
import lombok.Data;
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.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* [LOADTEST-METRICS] UserHome 热度 + 放大系数报告查询(方案 §3)。<br>
* 默认只统计线上流量(is_load_test=0),压测流量单独给合计数。<br>
* 例:GET /api/apimetrics/TopN?days=7&amp;top=10
*/
@RestController
@RequestMapping(value = Config.API_PATH + "/apimetrics/")
public class ApiMetricsController {
@Autowired
private ApiMetricsRepository repository;
/**
* 报告:入站 TopN、出站 oapi TopN、入站/出站总量与放大系数。
*/
@GetMapping("/TopN")
public ReportVo topN(
@RequestParam(value = "days", defaultValue = "1") Integer days,
@RequestParam(value = "top", defaultValue = "10") Integer top) {
int d = clamp(days == null ? 1 : days, 1, 30);
int n = clamp(top == null ? 10 : top, 1, 100);
LocalDateTime since = LocalDateTime.now().minusDays(d);
ReportVo vo = new ReportVo();
vo.setGeneratedAt(LocalDateTime.now().toString());
vo.setDays(d);
vo.setTop(n);
Object[] in = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 0));
vo.setInCnt(asLong(in, 0));
vo.setInAvgCostMs(avg(asLong(in, 0), asLong(in, 1)));
vo.setInErrCnt(asLong(in, 2));
Object[] out = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 0));
vo.setOutCnt(asLong(out, 0));
vo.setOutAvgCostMs(avg(asLong(out, 0), asLong(out, 1)));
vo.setOutErrCnt(asLong(out, 2));
// 放大系数 = 出站 oapi 调用数 / 入站请求数(首屏放大问题的量化,方案 §5 缓存决策输入)
vo.setAmplification(vo.getInCnt() <= 0 ? 0.0
: Math.round(vo.getOutCnt() * 10000.0 / vo.getInCnt()) / 100.0);
vo.setTopInUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_IN, 0), n));
vo.setTopOutUris(toRows(repository.aggregateByUri(since, ApiMetricsEntity.DIRECTION_OUT, 0), n));
// 压测流量合计(单列口径,不与线上热度混算)
Object[] ltIn = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_IN, 1));
Object[] ltOut = firstRow(repository.aggregateTotal(since, ApiMetricsEntity.DIRECTION_OUT, 1));
vo.setLoadTestInCnt(asLong(ltIn, 0));
vo.setLoadTestOutCnt(asLong(ltOut, 0));
return vo;
}
private List<UriRow> toRows(List<Object[]> aggregated, int limit) {
List<UriRow> rows = new ArrayList<>();
for (Object[] r : aggregated) {
UriRow row = new UriRow();
row.setUri((String) r[0]);
row.setMethod((String) r[1]);
row.setCnt(asLong(r, 2));
row.setAvgCostMs(avg(asLong(r, 2), asLong(r, 3)));
row.setErrCnt(asLong(r, 4));
rows.add(row);
if (rows.size() >= limit) {
break;
}
}
return rows;
}
/**
* Spring Data 对「无 group by 的多列 select」返回 Object[]{行数组} 嵌套结构
* (每个元素是一行的 Object[],不是标量),直接下标取会 ClassCastException,统一先解一层。
*/
private Object[] firstRow(Object[] arr) {
if (arr != null && arr.length > 0 && arr[0] instanceof Object[]) {
return (Object[]) arr[0];
}
return arr;
}
private long asLong(Object[] arr, int i) {
if (arr == null || i >= arr.length || arr[i] == null) {
return 0L;
}
return ((Number) arr[i]).longValue();
}
private double avg(long cnt, long total) {
return cnt <= 0 ? 0.0 : Math.round(total * 100.0 / cnt) / 100.0;
}
private int clamp(int v, int min, int max) {
return v < min ? min : (v > max ? max : v);
}
/** 报告 VO(嵌套声明,压测结束随包整体删除) */
@Data
public static class ReportVo {
private String generatedAt;
private Integer days;
private Integer top;
/** 入站请求总数 */
private Long inCnt;
private Double inAvgCostMs;
private Long inErrCnt;
/** 出站 UserBase oapi 调用总数 */
private Long outCnt;
private Double outAvgCostMs;
private Long outErrCnt;
/** 放大系数 = outCnt / inCnt(1 次入站扇出多少次跨仓调用) */
private Double amplification;
/** 入站热度 TopN */
private List<UriRow> topInUris;
/** 出站 oapi 热度 TopN(即 UserBase 侧被扇出的接口) */
private List<UriRow> topOutUris;
/** 压测流量合计(单列,不混入上面各项) */
private Long loadTestInCnt;
private Long loadTestOutCnt;
}
@Data
public static class UriRow {
private String uri;
private String method;
private Long cnt;
private Double avgCostMs;
private Long errCnt;
}
}
@@ -0,0 +1,81 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
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.Index;
import javax.persistence.Table;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* [LOADTEST-METRICS] UserHome 访问频次指标(60s 切片聚合,方案 §3)。<br>
* direction=in:入站 HTTP 请求(Filter 层采集);<br>
* direction=out:出站 UserBase oapi 调用(切 OpenApiHttpClient 采集);<br>
* 两者之比即首屏「放大系数」(1 次入站扇出多少次跨仓调用)。<br>
* 表结构由 ddl-auto=update 自动创建;DBA 手工建表脚本见
* resources/sql/api_metrics_loadtest.sql。
*/
@NoArgsConstructor
@Data
@Entity
@Table(name = "api_metrics", indexes = {
@Index(name = "idx_time", columnList = "stat_time"),
@Index(name = "idx_direction_time", columnList = "direction, stat_time")
})
public class ApiMetricsEntity implements Serializable {
private static final long serialVersionUID = 1L;
public static final String DIRECTION_IN = "in";
public static final String DIRECTION_OUT = "out";
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/** 统计切片时间(该 60s 窗口的结束时刻) */
@Column(name = "stat_time", nullable = false, columnDefinition = "DATETIME")
private LocalDateTime statTime;
/** 规范化路由:入站为本地路径(去 query、数字段归一);出站为 oapi path(如 /oapi/app/list) */
@Column(name = "uri", nullable = false, length = 200)
private String uri;
/** GET/POST/PUT/DELETE */
@Column(name = "method", nullable = false, length = 10)
private String method;
/** in=入站请求,out=出站 oapi 调用 */
@Column(name = "direction", nullable = false, length = 8)
private String direction;
/** 窗口内请求次数 */
@Column(name = "cnt", nullable = false)
private Long cnt;
/** 窗口内耗时累计 ms */
@Column(name = "total_cost", nullable = false)
private Long totalCost;
/** 窗口内错误次数:入站为 HTTP 非 200;出站为抛异常(HTTP 非 2xx / IO 错误) */
@Column(name = "err_cnt", nullable = false)
private Long errCnt;
/** 压测流量单列:入站看 X-Load-Test 头,出站继承触发它的入站请求标记 */
@Column(name = "is_load_test", nullable = false, columnDefinition = "TINYINT(1)")
private Integer isLoadTest;
}
@@ -0,0 +1,113 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.1)
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
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;
/**
* [LOADTEST-METRICS] UserHome 入站热度采集过滤器(direction=in)。<br>
* 挂 /* 最前置:连 CAS 未登录被 302 重定向的请求也计入(真实的入站压力口径)。<br>
* 排除 /upload/**(静态文件,路径基数不可控)与 /actuator/**(prometheus 周期抓取,
* 会污染热度),只统计 API 热度。
*/
public class ApiMetricsFilter implements Filter {
/** 压测流量标记头(方案 §2.4,k6 全部请求携带;出站切面会继承此标记) */
public static final String HEADER_LOAD_TEST = "X-Load-Test";
private static final int MAX_URI_LEN = 200;
private final ApiMetricsCollector collector;
public ApiMetricsFilter(ApiMetricsCollector collector) {
this.collector = collector;
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse res = (HttpServletResponse) response;
String uri = normalizeUri(req);
if (uri.startsWith("/upload/") || uri.startsWith("/actuator/")) {
chain.doFilter(request, response);
return;
}
String method = req.getMethod();
boolean loadTest = isLoadTest(req);
long start = System.currentTimeMillis();
boolean error = false;
try {
chain.doFilter(request, response);
error = res.getStatus() != 200;
} catch (IOException | ServletException e) {
error = true;
throw e;
} finally {
collector.record(uri, method, ApiMetricsEntity.DIRECTION_IN,
System.currentTimeMillis() - start, error, loadTest);
}
}
/**
* 规范化路由:去 context-path、去 query、纯数字路径段归一为 {id}
* (如 /mobiapi/app/detail/12 → /mobiapi/app/detail/{id}),防止路径参数撑爆维度基数。
*/
private String normalizeUri(HttpServletRequest req) {
String uri = req.getRequestURI();
String ctx = req.getContextPath();
if (ctx != null && ctx.length() > 1 && uri.startsWith(ctx)) {
uri = uri.substring(ctx.length());
}
if (uri.indexOf('/') >= 0) {
String[] segs = uri.split("/");
StringBuilder sb = new StringBuilder(uri.length());
for (String seg : segs) {
if (seg.length() > 0 && isDigits(seg)) {
seg = "{id}";
}
sb.append('/').append(seg);
}
uri = sb.toString();
}
if (uri.length() > MAX_URI_LEN) {
uri = uri.substring(0, MAX_URI_LEN);
}
return uri;
}
private boolean isDigits(String s) {
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
/** 压测标记:X-Load-Test 为 1/true/非 0 值即认为压测流量 */
private boolean isLoadTest(HttpServletRequest req) {
String v = req.getHeader(HEADER_LOAD_TEST);
if (v == null || v.trim().isEmpty()) {
return false;
}
v = v.trim();
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
}
}
@@ -0,0 +1,35 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.LocalDateTime;
import java.util.List;
/** [LOADTEST-METRICS] api_metrics 聚合查询(热度 + 放大系数报告用)。 */
public interface ApiMetricsRepository extends JpaRepository<ApiMetricsEntity, Long> {
/** 按方向聚合 uri 热度,按总次数降序 */
@Query("select e.uri, e.method, sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e "
+ "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest "
+ "group by e.uri, e.method order by sum(e.cnt) desc")
List<Object[]> aggregateByUri(@Param("since") LocalDateTime since,
@Param("direction") String direction,
@Param("isLoadTest") Integer isLoadTest);
/** 按方向全量汇总(总次数/总耗时/总错误),入站与出站合计相除即放大系数 */
@Query("select sum(e.cnt), sum(e.totalCost), sum(e.errCnt) from ApiMetricsEntity e "
+ "where e.statTime >= :since and e.direction = :direction and e.isLoadTest = :isLoadTest")
Object[] aggregateTotal(@Param("since") LocalDateTime since,
@Param("direction") String direction,
@Param("isLoadTest") Integer isLoadTest);
}
@@ -0,0 +1,134 @@
package com.yunhai.userhome.loadtestmetrics;
/* =============================================================================
* [LOADTEST-METRICS] 临时埋点 —— 《压测与oapi埋点统计方案.md》阶段二(方案 §3.2)
* 压测结束后整体移除:删除 loadtestmetrics 包 + 回退 pom.xml/application.yml
* 中 [LOADTEST-METRICS] 标记段 + DROP TABLE api_metrics。
* 移除步骤详见仓库根《埋点移除清单.md》。
* ============================================================================= */
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import javax.servlet.http.HttpServletRequest;
/**
* [LOADTEST-METRICS] 出站 UserBase oapi 调用计数(direction=out,方案 §3.2)。<br>
* 切在 yh-openapi-client 的 {@code YschOpenCall}(SDK 重构后所有出站开放接口调用的唯一
* 公共入口,Spring Bean)上:YschBaseOpenApi 静态门面、OpenApiUserClient / OpenApiAppClient
* 注入式调用全部经它发出,一处切面覆盖全部出站,不改动任何业务 Service 代码。<br>
* 入站请求数(direction=in)与出站调用数(direction=out)相除即「放大系数」,
* 是缓存层次决策的直接依据(方案 §5)。
*/
@Slf4j
@Aspect
@Component
public class OutboundOapiMetricsAspect {
private static final int MAX_URI_LEN = 200;
private final ApiMetricsCollector collector;
public OutboundOapiMetricsAspect(ApiMetricsCollector collector) {
this.collector = collector;
}
/**
* 拦截 YschOpenCall 全部公开调用方法(get/getList/post/postMultipart/download)。<br>
* 按名重载第 2 参均为 path(如 /oapi/app/list),据此归一化记 uri。
*/
@Around("execution(* com.yunhai.platform.openapi.core.YschOpenCall.*(..))")
public Object around(ProceedingJoinPoint point) throws Throwable {
String uri = resolveUri(point);
String method = resolveHttpMethod(point.getSignature().getName());
boolean loadTest = inheritLoadTestFlag();
long start = System.currentTimeMillis();
boolean error = false;
try {
return point.proceed();
} catch (Throwable e) {
error = true;
throw e;
} finally {
try {
collector.record(uri, method, ApiMetricsEntity.DIRECTION_OUT,
System.currentTimeMillis() - start, error, loadTest);
} catch (Exception recordEx) {
// 埋点自身绝不影响出站调用
log.warn("[LOADTEST-METRICS] 出站计数失败: {}", recordEx.getMessage());
}
}
}
/** 方法名 → HTTP 方法(getJson/get→GET,postJson/post/postMultipart→POST) */
private String resolveHttpMethod(String signatureName) {
return signatureName.startsWith("post") ? "POST" : "GET";
}
/** 取第 2 参 path 归一化;防御签名变化时记方法名兜底,不抛错 */
private String resolveUri(ProceedingJoinPoint point) {
try {
Object[] args = point.getArgs();
if (args.length >= 2 && args[1] instanceof String) {
return normalize((String) args[1]);
}
} catch (Exception ignore) {
// 兜底
}
return "oapi:" + point.getSignature().getName();
}
/** 出站 path 归一化:保证前导 /、纯数字段归一为 {id}、截断 200 */
private String normalize(String path) {
String p = path == null ? "" : path.trim();
if (p.isEmpty()) {
return "/";
}
if (!p.startsWith("/")) {
p = "/" + p;
}
int q = p.indexOf('?');
if (q >= 0) {
p = p.substring(0, q);
}
String[] segs = p.split("/");
StringBuilder sb = new StringBuilder(p.length());
for (String seg : segs) {
if (seg.length() > 0 && isDigits(seg)) {
seg = "{id}";
}
sb.append('/').append(seg);
}
p = sb.toString();
return p.length() > MAX_URI_LEN ? p.substring(0, MAX_URI_LEN) : p;
}
private boolean isDigits(String s) {
for (int i = 0; i < s.length(); i++) {
if (!Character.isDigit(s.charAt(i))) {
return false;
}
}
return true;
}
/** 出站调用的压测标记继承自触发它的入站请求(X-Load-Test 头);定时任务等无请求上下文则为 false */
private boolean inheritLoadTestFlag() {
if (RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes) {
HttpServletRequest req = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
String v = req.getHeader(ApiMetricsFilter.HEADER_LOAD_TEST);
if (v != null && !v.trim().isEmpty()) {
v = v.trim();
return !"0".equals(v) && !"false".equalsIgnoreCase(v);
}
}
return false;
}
}
@@ -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;
}
@@ -0,0 +1,15 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.Logo;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
import java.util.Optional;
public interface LogoRepository extends JpaRepository<Logo, Integer>, JpaSpecificationExecutor<Logo> {
List<Logo> findBySchIdAndType(Integer schId, Integer type);
Optional<Logo> findByTypeAndSchId(Integer type, Integer schId);
}
@@ -3,12 +3,16 @@ package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.MenuBar;
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 java.util.List;
public interface MenuBarRepository extends JpaRepository<MenuBar, Integer>, JpaSpecificationExecutor<MenuBar> {
List<MenuBar> findBySchId(Integer schId);
@Query("select m from MenuBar m left join fetch m.hideSchIds where m.schId = :schId")
List<MenuBar> findBySchId(@Param("schId") Integer schId);
List<MenuBar> findBySchIdIn(List<Integer> schIds);
@Query("select m from MenuBar m left join fetch m.hideSchIds where m.schId in :schIds")
List<MenuBar> findBySchIdIn(@Param("schIds") List<Integer> schIds);
}
@@ -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);
}
@@ -1,11 +0,0 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.UserCurrentRole;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserCurrentRoleRepository extends JpaRepository<UserCurrentRole, Integer> {
Optional<UserCurrentRole> findByUserId(Integer userId);
}
@@ -1,12 +1,31 @@
package com.yunhai.userhome.repository;
import com.yunhai.userhome.entity.UserSettings;
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Collection;
import java.util.Optional;
public interface UserSettingsRepository extends JpaRepository<UserSettings, Integer>, JpaSpecificationExecutor<UserSettings> {
/**
* desktopApps 为 LAZY 集合且 open-in-view=false,service 层无事务返回后 controller 仍需遍历该集合,
* 故查询时立即抓取,避免 LazyInitializationException。
*/
@EntityGraph(attributePaths = "desktopApps")
Optional<UserSettings> findByUserIdAndSchIdAndRoleId(Integer userId, Integer schId, Integer roleId);
/**
* 删除壁纸时将被删除壁纸引用的用户设置 wallpaper_url 置空,避免裂图(调用方需处于事务中)。
* user_settings 存展示形态(FILE_WEB_PATH 前缀 + 去 /upload),wall 表存原始形态,
* 两种形态都需传入匹配,漏形态会留下悬空引用(读侧拿到已删壁纸的死 URL 白板)。
*/
@Modifying
@Query("update UserSettings u set u.wallpaperUrl = null where u.wallpaperUrl in :urls")
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 {
/** 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。 */
/**
* 取当前用户可访问的应用全集(按应用类型分组,BoaAppType.id/name/apps)。
* 不带学校上下文:由 UserBase 侧按「当前激活身份学校 → 主归属学校」兜底推导生效学校。
*/
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取单个应用(在已拉取的全集中查找,避免额外跨仓请求)。 */
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);
}
@@ -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;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.SchSettings;
import java.util.List;
@@ -12,5 +13,55 @@ public interface ISchSettingsService {
Optional<SchSettings> findBySchId(Integer schId);
/** 原样读(无兜底):学校默认主题档等"可空语义"场景专用——查无返回 empty,不合成默认对象 */
Optional<SchSettings> findBySchIdRaw(Integer schId);
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;
}
}
}
@@ -5,13 +5,14 @@ import com.yunhai.userhome.entity.UserCurrentRole;
import java.util.Optional;
/**
* 用户当前激活角色(user_current_role 表读写)。
* 用户当前激活角色访问(UserBase 单一权威源,经 SDK 走 oapi,本地无存储)。<br>
* 保留接口以稳定既有 81 处消费点(仅读 roleId/schId);实现由本地表改为 UserBase 开放接口。
*/
public interface IUserCurrentRoleService {
/** 取用户当前角色(可能不存在)。 */
/** 取用户当前激活角色(未切换时兜底主身份;无任何身份返回 Optional.empty)。 */
Optional<UserCurrentRole> findById(Integer userId);
/** 保存/覆盖用户当前角色。 */
/** 切换用户当前激活角色(目标身份须属该用户,由 UserBase 校验)。 */
void save(UserCurrentRole role);
}
@@ -1,6 +1,8 @@
package com.yunhai.userhome.service;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.LightApp;
import com.yunhai.userhome.entity.Logo;
import com.yunhai.userhome.entity.UserSettings;
import com.yunhai.userhome.entity.UserSettingsDesktopApp;
import com.yunhai.userhome.entity.Wallpaper;
@@ -12,12 +14,24 @@ public interface IUserSettingsService {
List<Wallpaper> findWallList(Integer userId);
void deleteWallById(Integer id);
/** 管理视角壁纸列表(系统设置页):系统 + 目标学校,不含个人壁纸;isEdit 按调用者权限标记 */
List<Wallpaper> findSchWallpaperManageList(BoaUser user, Integer targetSchId);
/** 管理视角 Logo 列表(系统设置页):系统 + 目标学校 */
List<Logo> findSchLogoManageList(BoaUser user, Integer targetSchId);
void deleteWallById(Integer id, BoaUser user);
void saveWallpaper(Wallpaper wallpaper);
void checkWallpaper(Integer userId, String wallpaperUrl);
List<Logo> findLogoList(Integer userId);
void saveLogo(Logo logo);
void deleteLogoById(Integer id, BoaUser user);
Optional<UserSettings> findUserSettings(Integer userId);
void saveUserSettings(UserSettings userSettings);
@@ -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;
}
}
@@ -0,0 +1,788 @@
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.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.entity.UserCurrentRole;
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.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* UserMsg 消息中心签名代理(§3.5 / BP-R3-1):出站走 {@link YschOpenCall} 泛化签名直调
* (Tier 0,per-target base-path 剥离——客户端签名 path ≡ UserMsg requestURI 减 context-path),
* UserHome 前端不直连 UserMsg。
* <ul>
* <li>消息读取(列表/摘要/未读数/按应用计数/全部已读)走 SDK msg 域类型化读方法
* ({@link OpenApiMsgClient}),并带「当前激活身份」所属学校口径 schId
* ({@link #resolveReadSchId})——按学校隔离读取侧;</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>
* 2026-09-20 事故修复:原实现用共享 IDHttp 出站,其 AppSignInterceptor 只剥<b>本应用</b>
* base-path(/userbase),目标 URL 带 /usermsg ctx 时签名路径多一层前缀 → UserMsg 验签 401,
* 列表/角标/票据全部静默降级为空。改 YschOpenCall 后 {@code usermsg-base-url} 解析为
* {@link TargetSpec}(host + /usermsg ctx),签名按目标 ctx 剥离,与 AppAuthFilter 对齐。
* 目标地址来源:usermsg-base-url 显式配置优先(ad-hoc TargetSpec,泛化直调与类型化读方法同一寻址),
* 其次服务目录 usermsg(yh.open.services / 枚举兜底)。
* UserMsg 不可达时降级空结果(铃铛不 500),恢复后自愈。旧 user_message 本地表链路不动。
*/
@Slf4j
@Service
public class MsgCenterProxyService {
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 MsgCenterProperties properties;
private final IUserCurrentRoleService userCurrentRoleService;
/** 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.properties = properties;
this.userCurrentRoleService = userCurrentRoleService;
}
private boolean enabled() {
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 签名剥离);否则按名走服务目录 */
private JsonNode get(String path, Map<String, String> query) {
String base = properties.getUsermsgBaseUrl();
if (StringUtils.hasText(base)) {
return openCall.get(TargetSpec.fromUrl(base.trim()), path, query, JsonNode.class);
}
return openCall.get(TARGET_USERMSG, path, query, JsonNode.class);
}
private JsonNode post(String path, Map<String, String> query, Object body) {
String base = properties.getUsermsgBaseUrl();
if (StringUtils.hasText(base)) {
return openCall.post(TargetSpec.fromUrl(base.trim()), path, query, body, JsonNode.class);
}
return openCall.post(TARGET_USERMSG, path, query, body, JsonNode.class);
}
/**
* 消息分页列表(已过滤删除/过期):{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();
if (!enabled()) {
return empty;
}
PerRequestAuthz authz = new PerRequestAuthz(userId);
AppDict dict = appDict(userId);
try {
MsgListQuery query = new MsgListQuery();
query.setUserId(userId);
query.setPageIndex(pageIndex == null || pageIndex < 1 ? 1 : pageIndex);
query.setPageSize(pageSize == null || pageSize < 1 ? 10 : Math.min(pageSize, 100));
query.setOnlyUnread(Boolean.TRUE.equals(onlyUnread));
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<>();
if (page != null && page.getContent() != null) {
for (MsgListItem item : page.getContent()) {
rows.add(rewrite(item, authz, dict));
}
}
Map<String, Object> out = new HashMap<>();
out.put("total", page == null ? 0 : page.getTotal());
out.put("list", rows);
return out;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 列表不可达(降级空)userId={}:{}", userId, e.toString());
return empty;
}
}
/**
* 铃铛摘要:未读数 + 各应用待办角标 + 分类计数(停机各自降级 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<>();
out.put("unreadCount", 0L);
out.put("todos", new ArrayList<>());
if (!enabled()) {
return out;
}
boolean summaryFetched = false;
try {
UserMsgSummary vo = msgClient().summary(userId, msgSchId);
if (vo != null) {
out.put("unreadCount", vo.getUnreadCount());
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) {
log.warn("[msgcenter] UserMsg 未读数不可达(降级 0)userId={}:{}", userId, e.toString());
}
}
try {
Map<String, String> q = new LinkedHashMap<>();
q.put("userId", String.valueOf(userId));
if (todoSchId != null) {
q.put("schId", String.valueOf(todoSchId));
}
JsonNode todos = get("/oapi/msg/todo/list", q);
List<Map<String, Object>> rows = new ArrayList<>();
if (todos != null && todos.isArray()) {
for (JsonNode t : todos) {
Map<String, Object> row = new LinkedHashMap<>();
// appKey 属后端签名标识不下发(0e57022 口径收尾);待办角标前端已改走 /msgCenter/countByApp
row.put("count", t.has("count") ? t.get("count").asInt() : 0);
rows.add(row);
}
}
out.put("todos", rows);
} catch (Exception e) {
log.warn("[msgcenter] UserMsg 待办角标不可达(降级空)userId={}:{}", userId, e.toString());
}
return out;
}
/**
* 按应用消息计数(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()) {
return;
}
Map<String, Object> body = new HashMap<>();
body.put("msgIds", msgIds);
body.put("userId", userId);
if (schId != null) {
body.put("schId", schId);
}
post("/oapi/msg/read", new HashMap<>(), body);
}
/** 全部已读(按 schId 学校口径限定,D6:只标本校+平台未读,他校未读不受影响;schId null=全部) */
public void readAll(Integer userId, Integer schId) {
if (!enabled()) {
return;
}
msgClient().readAll(userId, schId);
}
/**
* 按 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;
}
/**
* WS 握手票据:以应用身份为登录人换一次性票据(UserMsg /oapi/msg/ws/ticket),
* 拼上浏览器可达的 WS 端点返回。未配置 WS 地址 / UserMsg 不可达 → null(前端维持轮询兜底表现)。
*/
public String createWsTicket(Integer userId) {
if (!enabled() || !StringUtils.hasText(properties.getUsermsgWsUrl())) {
return null;
}
try {
Map<String, Object> body = new HashMap<>();
body.put("userId", userId);
JsonNode data = post("/oapi/msg/ws/ticket", new HashMap<>(), body);
String ticket = data == null || !data.hasNonNull("ticket") ? null : data.get("ticket").asText();
if (!StringUtils.hasText(ticket)) {
return null;
}
String wsUrl = properties.getUsermsgWsUrl().trim();
return wsUrl + (wsUrl.contains("?") ? "&" : "?") + "ticket=" + ticket;
} catch (Exception e) {
log.warn("[msgcenter] UserMsg WS 票据不可达(降级 null,前端不建立长连接)userId={}:{}",
userId, e.toString());
return null;
}
}
// ===== 出参改写 =====
/**
* 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<>();
out.put("msgId", row.getMsgId());
out.put("title", row.getTitle());
out.put("content", row.getContent());
out.put("msgType", row.getMsgType());
out.put("jumpType", row.getJumpType());
out.put("jumpPath", row.getJumpPath());
out.put("jumpApp", row.getJumpApp());
// 归属换源(页面属主优先):appId/appName 出参 jumpApp(值域=app_code)非空且 byCode 查到 → 用业务方应用;
// jumpApp 为空/查无(脏值)/UserBase 不可达 → 回落发送方 byKey(宁可显示流程中心,不抛错)。
// 其余出参(jumpApp/jumpUrl/showJump/bizType…)不动,前端已依赖。
String jumpApp = row.getJumpApp();
String ownerCode = jumpApp == null ? null : jumpApp.trim();
AppDictEntry entry = null;
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;
}
/**
* 基址解析(B2 跳转基址统一,C2 收口):jumpType=1 且 jumpPath 非空时——
* <ol>
* <li>绝对 URL(http/https)直接透传,不二次拼基址(存量兼容);</li>
* <li>应用注册表严格二选一:jumpApp(值域=app_code)有值 → 只认 by-code 结果;
* jumpApp 为空才按发送方 appKey 走 by-key(自发场景发送方=页面归属)。
* 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;
}
String path = jumpPath.trim();
if (path.startsWith("http://") || path.startsWith("https://")) {
return path;
}
if (userbaseDown()) {
return null;
}
String p = path.startsWith("/") ? path : "/" + path;
String appKey = row.getAppKey();
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;
}
}
private static Map<String, Object> emptyPage() {
Map<String, Object> out = new HashMap<>();
out.put("total", 0);
out.put("list", new ArrayList<>());
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;
}
}
@@ -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();
}
}
@@ -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;
}
}
@@ -9,6 +9,7 @@ import com.yunhai.userhome.service.IMenuBarService;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import java.util.stream.Collectors;
@@ -55,6 +56,7 @@ public class MenuBarServiceImpl implements IMenuBarService {
}
@Override
@Transactional
public void saveMenuBar(MenuBar menuBar) {
if (menuBar.getId() != null) {
Optional<MenuBar> optional = menuBarRepository.findById(menuBar.getId());
@@ -76,6 +78,7 @@ public class MenuBarServiceImpl implements IMenuBarService {
}
@Override
@Transactional
public void toggleMenuBarShow(Integer id, Integer schId) {
Optional<MenuBar> optionalMenuBar = menuBarRepository.findById(id);
if (!optionalMenuBar.isPresent())
@@ -1,6 +1,6 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.openapi.client.YschBaseOpenApi;
import com.yunhai.platform.openapi.base.YschBaseOpenApi;
import com.yunhai.platform.utils.enums.ClientEnum;
import com.yunhai.platform.utils.model.BoaApp;
import com.yunhai.platform.utils.model.BoaAppType;
@@ -21,19 +21,35 @@ public class OpenApiAppRemoteServiceImpl implements AppRemoteService {
@Override
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) {
return Collections.emptyList();
}
// PC 浏览器端;UserBase 侧已按 userId 权限过滤(组开通 ∪ 角色模块所属应用,超管全量)
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER);
// 前端 getCurSchId 对无学校身份(超管/平台账号)下发哨兵 -1(store: schId || -1),非合法学校主键;
// UserBase 2026-10-12 硬闸门对显式 schoolId 先过 assertSchoolId,-1 会被拒("学校分组不存在"),
// 且超管豁免(isSuperAdmin bypass)在校验之后根本走不到——边界归一为 null 交服务端按激活身份/主归属兜底。
if (schoolId != null && schoolId <= 0) {
schoolId = null;
}
// PC 浏览器端;schoolId 透传 UserBase 做学校单点开通收窄(为空则服务端按身份/主归属兜底)
return YschBaseOpenApi.getUserApp(user.getId(), ClientEnum.PC_BROWSER, schoolId);
}
@Override
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) {
return Optional.empty();
}
return getUserApps(user).stream()
return getUserApps(user, schoolId).stream()
.filter(t -> t.getApps() != null)
.flatMap(t -> t.getApps().stream())
.filter(a -> appId.equals(a.getId()))
@@ -1,22 +1,20 @@
package com.yunhai.userhome.service.impl;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.SchSettings;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.repository.SchSettingsRepository;
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.web.client.RestTemplate;
import java.util.Optional;
/**
* 学校设置服务:行为对齐旧 UserHome SchSettingsService。
* <p>关键:{@code findBySchId} 不查本地表,而是跨仓调用 {@code SCH_CONF_URL/oapi/getSchSetting},
* 旧版如此(schConfig 服务为 homeImgUrl/isEnableDesktop/isLinkDesktop 等字段的权威来源),
* 因此本地 sch_settings 表变更类能力(saveSchTheme/saveSchLogo)本期仅保留 P1 占位写入本地表。</p>
* 学校设置服务:学校设置完全本地化(sch_settings 表),不再依赖跨仓 schConfig。
* <p>findBySchId 直接查本地表,查不到兜底默认主题;saveSchTheme/saveSchLogo 写本地表即可闭环。</p>
*/
@Service
public class SchSettingsServiceImpl implements ISchSettingsService {
@@ -24,11 +22,8 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
@Autowired
private SchSettingsRepository schSettingsRepository;
@Value("${SCH_CONF_URL:}")
private String schConfUrl;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private IUserCurrentRoleService userCurrentRoleService;
@Override
public void save(SchSettings schSettings) {
@@ -36,46 +31,122 @@ public class SchSettingsServiceImpl implements ISchSettingsService {
}
@Override
public Optional<SchSettings> findBySchId(Integer schId) {
SchSettings model = new SchSettings();
try {
if (schConfUrl == null || schConfUrl.isEmpty()) {
return Optional.of(localFallbackBySchId(schId));
}
String apiPath = schConfUrl + "/oapi/getSchSetting?schId=" + schId;
String resultStr = restTemplate.getForObject(apiPath, String.class);
if (resultStr == null || resultStr.isEmpty()) {
return Optional.of(localFallbackBySchId(schId));
}
JsonNode root = objectMapper.readTree(resultStr);
JsonNode dataNode = root.get("data");
if (root.has("code") && "0".equals(root.get("code").asText()) && dataNode != null && !dataNode.isNull()) {
model = objectMapper.convertValue(dataNode, SchSettings.class);
} else {
return Optional.of(localFallbackBySchId(schId));
}
} catch (Exception e) {
return Optional.of(localFallbackBySchId(schId));
}
return Optional.of(model);
public Optional<SchSettings> findBySchIdRaw(Integer schId) {
return schSettingsRepository.findFirstBySchId(schId);
}
/** 跨仓不通时先查本地表,再兜底默认主题(对齐旧版异常分支)。 */
private SchSettings localFallbackBySchId(Integer schId) {
@Override
public Optional<SchSettings> findBySchId(Integer schId) {
Optional<SchSettings> local = schSettingsRepository.findFirstBySchId(schId);
if (local.isPresent()) {
return local.get();
return local;
}
SchSettings def = new SchSettings();
def.setSchId(schId);
def.setThemeName("default-theme");
def.setAppBgUrl("");
def.setLogoUrl("");
return def;
return Optional.of(def);
}
@Override
public java.util.List<SchSettings> findBySchIds(java.util.List<Integer> 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.BoaUser;
import com.yunhai.userhome.entity.UsedApp;
import com.yunhai.userhome.model.AppIconEntryVo;
import com.yunhai.userhome.model.SetType;
import com.yunhai.userhome.repository.UsedAppRepository;
import com.yunhai.userhome.service.AppIconKeyService;
import com.yunhai.userhome.service.AppRemoteService;
import com.yunhai.userhome.service.UsedAppService;
import org.springframework.beans.factory.annotation.Autowired;
@@ -34,6 +36,9 @@ public class UsedAppServiceImpl implements UsedAppService {
@Autowired
private AppRemoteService appRemoteService;
@Autowired
private AppIconKeyService appIconKeyService;
@Override
public void save(UsedApp usedApp) {
Optional<UsedApp> model = usedAppRepository
@@ -138,6 +143,7 @@ public class UsedAppServiceImpl implements UsedAppService {
m.put("name", app.getName());
m.put("icon", app.getIcon());
m.put("url", app.getWebAddr());
putIconKeys(m, app.getId());
list.add(m);
}
} catch (NumberFormatException ignore) {
@@ -147,6 +153,13 @@ public class UsedAppServiceImpl implements UsedAppService {
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 列表。 */
private List<BoaApp> flattenApps(List<BoaAppType> types) {
List<BoaApp> apps = new ArrayList<>();
@@ -1,26 +1,68 @@
package com.yunhai.userhome.service.impl;
import com.yunhai.platform.openapi.base.OpenApiUserClient;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.userhome.entity.UserCurrentRole;
import com.yunhai.userhome.repository.UserCurrentRoleRepository;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Optional;
/**
* 用户当前激活角色访问实现:数据源由本地 {@code user_current_role} 表迁移至 UserBase 单一权威源
* (多角色迁移方案)。<br>
* {@link #findById} → UserBase {@code /oapi/identity/current}(未切换兜底 is_main 主身份);
* {@link #save} → UserBase {@code /oapi/identity/switch}(目标身份属主校验 + 白名单,均在 UserBase 侧)。
*/
@Service
public class UserCurrentRoleServiceImpl implements IUserCurrentRoleService {
@Autowired
private UserCurrentRoleRepository repository;
private OpenApiUserClient openApiUserClient;
@Override
public Optional<UserCurrentRole> findById(Integer userId) {
return repository.findByUserId(userId);
if (userId == null) {
return Optional.empty();
}
BoaRoleInfo info = openApiUserClient.getCurrentIdentity(userId);
if (info == null) {
// 无当前身份(超管等平台账号):兜底平台级默认身份(schId=0),避免下游 orElseThrow 抛错 / 拿空菜单
return Optional.of(platformDefaultRole(userId));
}
return Optional.of(toRole(info));
}
@Override
public void save(UserCurrentRole role) {
repository.save(role);
if (role == null || role.getUserId() == null) {
throw new IllegalArgumentException("用户ID不能为空");
}
openApiUserClient.switchIdentity(role.getUserId(), role.getRoleId(), role.getSchId());
}
/** BoaRoleInfo → UserCurrentRole(字段同名,纯 DTO 映射) */
private UserCurrentRole toRole(BoaRoleInfo info) {
if (info == null) {
return null;
}
UserCurrentRole role = new UserCurrentRole();
role.setRoleId(info.getRoleId());
role.setRoleName(info.getRoleName());
role.setSchId(info.getSchId());
role.setSchName(info.getSchName());
role.setChildId(info.getChildId());
role.setChildName(info.getChildName());
return role;
}
/** 平台级默认身份:超管 roleId=0、schId=0(对齐各控制器 type==1 → schId=0 的既有约定) */
private UserCurrentRole platformDefaultRole(Integer userId) {
UserCurrentRole role = new UserCurrentRole();
role.setUserId(userId);
role.setRoleId(0);
role.setSchId(0);
return role;
}
}
@@ -2,18 +2,23 @@ package com.yunhai.userhome.service.impl;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import com.yunhai.userhome.entity.*;
import com.yunhai.userhome.repository.LightAppRepository;
import com.yunhai.userhome.repository.LogoRepository;
import com.yunhai.userhome.repository.UserSettingsDesktopAppRepository;
import com.yunhai.userhome.repository.UserSettingsRepository;
import com.yunhai.userhome.repository.WallpaperRepository;
import com.yunhai.userhome.service.IUserCurrentRoleService;
import com.yunhai.userhome.service.IUserSettingsService;
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.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.persistence.criteria.Predicate;
import java.util.ArrayList;
@@ -30,6 +35,9 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
@Autowired
private WallpaperRepository wallpaperRepository;
@Autowired
private LogoRepository logoRepository;
@Autowired
private UserSettingsRepository userSettingsRepository;
@@ -42,6 +50,13 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
@Autowired
private UserSettingsDesktopAppRepository userSettingsDesktopAppRepository;
@Value("${FILE_PATH:./upload/}")
private String FILE_PATH;
/** 与 UserSettingsController 同源:展示形态 = 该前缀 + 去掉 /upload 的池内原始路径 */
@Value("${FILE_WEB_PATH:}")
private String FILE_WEB_PATH;
@Override
public List<Wallpaper> findWallList(Integer userId) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
@@ -63,10 +78,76 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
}
@Override
public void deleteWallById(Integer id) {
if (!wallpaperRepository.existsById(id))
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR);
wallpaperRepository.deleteById(id);
public List<Wallpaper> findSchWallpaperManageList(BoaUser user, Integer targetSchId) {
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
boolean superAdmin = user.getType() == 1;
if (!superAdmin && !OtherUtil.isAdminById(currentRole.getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
Integer schId = superAdmin ? (targetSchId == null ? 0 : targetSchId) : currentRole.getSchId();
List<Wallpaper> resultList = new ArrayList<>();
List<Wallpaper> baseList = wallpaperRepository.findBySchIdAndType(0, 0);
List<Wallpaper> schList = wallpaperRepository.findBySchIdAndType(schId, 1);
if (superAdmin) {
baseList.forEach(x -> x.setIsEdit(true));
schList.forEach(x -> x.setIsEdit(true));
} else {
schList.forEach(x -> x.setIsEdit(true));
}
resultList.addAll(baseList);
resultList.addAll(schList);
return resultList;
}
@Override
public List<Logo> findSchLogoManageList(BoaUser user, Integer targetSchId) {
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
boolean superAdmin = user.getType() == 1;
if (!superAdmin && !OtherUtil.isAdminById(currentRole.getRoleId())) {
throw new BizException(ErrorCode.FORBIDDEN);
}
Integer schId = superAdmin ? (targetSchId == null ? 0 : targetSchId) : currentRole.getSchId();
List<Logo> resultList = new ArrayList<>();
List<Logo> baseList = logoRepository.findBySchIdAndType(0, 0);
List<Logo> schList = logoRepository.findBySchIdAndType(schId, 1);
if (superAdmin) {
baseList.forEach(x -> x.setIsEdit(true));
schList.forEach(x -> x.setIsEdit(true));
} else {
schList.forEach(x -> x.setIsEdit(true));
}
resultList.addAll(baseList);
resultList.addAll(schList);
return resultList;
}
@Override
@Transactional
public void deleteWallById(Integer id, BoaUser user) {
Wallpaper wallpaper = wallpaperRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
// 属主校验:超管可删任意;校管理员可删本校;普通用户仅可删自己上传的个人壁纸
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
boolean superAdmin = user.getType() == 1;
boolean ownPersonal = wallpaper.getType() != null && wallpaper.getType() == 2
&& wallpaper.getUserId() != null && wallpaper.getUserId().equals(user.getId());
boolean schAdminOwn = wallpaper.getType() != null && wallpaper.getType() == 1
&& OtherUtil.isAdminById(currentRole.getRoleId())
&& currentRole.getSchId() != null && currentRole.getSchId().equals(wallpaper.getSchId());
if (!(superAdmin || ownPersonal || schAdminOwn)) {
throw new BizException(ErrorCode.FORBIDDEN);
}
// 使用中清理:被删壁纸被用户设置引用时置空,避免裂图。
// 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());
wallpaperRepository.delete(wallpaper);
}
@Override
@@ -81,6 +162,56 @@ public class UserSettingsServiceImpl implements IUserSettingsService {
userSettingsRepository.save(model);
}
@Override
public List<Logo> findLogoList(Integer userId) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
if (!currentRoleOpt.isPresent()) {
return new ArrayList<>();
}
UserCurrentRole currentRole = currentRoleOpt.get();
List<Logo> resultList = new ArrayList<>();
List<Logo> baseList = logoRepository.findBySchIdAndType(0, 0);
List<Logo> schList = logoRepository.findBySchIdAndType(currentRole.getSchId(), 1);
if (currentRole.getSchId() == 0) baseList.forEach(x -> x.setIsEdit(true));
if (OtherUtil.isAdminById(currentRole.getRoleId())) schList.forEach(x -> x.setIsEdit(true));
resultList.addAll(baseList);
resultList.addAll(schList);
return resultList;
}
@Override
public void saveLogo(Logo logo) {
// 同 type+schId 已存在则覆盖(一校/系统各一行)
Optional<Logo> exist = logoRepository.findByTypeAndSchId(logo.getType(), logo.getSchId());
if (exist.isPresent()) {
Logo model = exist.get();
model.setLogoUrl(logo.getLogoUrl());
model.setUserId(logo.getUserId());
logoRepository.save(model);
} else {
logoRepository.save(logo);
}
}
@Override
@Transactional
public void deleteLogoById(Integer id, BoaUser user) {
Logo logo = logoRepository.findById(id)
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
// 属主校验:超管可删系统默认;校管理员仅可删本校
UserCurrentRole currentRole = userCurrentRoleService.findById(user.getId())
.orElseThrow(() -> new BizException(ErrorCode.INTERNAL_SERVER_ERROR));
boolean superAdmin = user.getType() == 1;
boolean schAdminOwn = logo.getType() != null && logo.getType() == 1
&& OtherUtil.isAdminById(currentRole.getRoleId())
&& currentRole.getSchId() != null && currentRole.getSchId().equals(logo.getSchId());
if (!(superAdmin || schAdminOwn)) {
throw new BizException(ErrorCode.FORBIDDEN);
}
FileUtil.deleteFile(FILE_PATH, logo.getLogoUrl());
logoRepository.delete(logo);
}
@Override
public Optional<UserSettings> findUserSettings(Integer userId) {
Optional<UserCurrentRole> currentRoleOpt = userCurrentRoleService.findById(userId);
@@ -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;
}
}
}
@@ -1,18 +1,34 @@
package com.yunhai.userhome.utils;
import cn.hutool.core.util.IdUtil;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.file.attribute.FileTime;
import java.time.Instant;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Locale;
import java.util.Set;
import java.util.UUID;
import java.util.zip.Deflater;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
/**
* File工具类(对齐旧 UserHome utils.FileUtil,仅保留 uploadImg 方法——本期迁移实际使用的唯一方法)。
*/
public class FileUtil {
/** 允许上传的图片扩展名(与前端 accept 对齐,含 icon 场景的 svg) */
private static final Set<String> ALLOWED_EXT = new HashSet<>(Arrays.asList(".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"));
/**
* 上传图片(byte[] 版),返回生成的文件名。
*/
@@ -32,13 +48,14 @@ public class FileUtil {
fos.write(imgData);
fos.close();
} catch (IOException e) {
e.printStackTrace();
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
}
return imgName;
}
/**
* 上传图片(MultipartFile 版),保留原始扩展名,返回生成的文件名。
* <p>扩展名不在白名单内抛 {@link BizException}(服务端校验,防绕过前端 accept)。</p>
*/
public static String uploadImg(MultipartFile file, String pathName) {
String imgName = null;
@@ -50,7 +67,10 @@ public class FileUtil {
int index = fileName.lastIndexOf(".");
String ext = "";
if (index > 0) {
ext = fileName.substring(index);
ext = fileName.substring(index).toLowerCase(Locale.ROOT);
}
if (!ALLOWED_EXT.contains(ext)) {
throw new BizException(ErrorCode.CUSTOM, "不支持的图片格式");
}
for (int i = 0; i < 100; i++) {
imgName = UUID.randomUUID().toString().substring(0, 8) + ext;
@@ -63,8 +83,175 @@ public class FileUtil {
fos.write(imgData);
fos.close();
} catch (IOException e) {
e.printStackTrace();
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
}
return imgName;
}
/**
* 删除上传的物理文件(按 /upload/ 相对 URL 还原路径)。
* <p>非本地上传(完整 URL / 跨仓地址)或文件不存在时静默跳过。</p>
*/
public static void deleteFile(String basePath, String relativeUrl) {
if (relativeUrl == null || relativeUrl.isEmpty() || !relativeUrl.startsWith("/upload/")) {
return;
}
String rel = relativeUrl.substring("/upload/".length());
File file = new File(basePath, rel);
if (file.exists()) {
file.delete();
}
}
/**
* 通用文件上传(MultipartFile 版),保留原始扩展名,返回生成的文件名。
* <p>与 {@link #uploadImg(MultipartFile, String)} 的区别:不做图片扩展名白名单校验,
* 供模板上传(zip/rar)等非图片场景使用,扩展名合法性由调用方自行校验。</p>
*/
public static String uploadFile(MultipartFile file, String pathName) {
String imgName = null;
String path = null;
File floorFile = null;
try {
byte[] fileData = file.getBytes();
String fileName = file.getOriginalFilename();
int index = fileName == null ? -1 : fileName.lastIndexOf(".");
String ext = "";
if (index > 0) {
ext = fileName.substring(index).toLowerCase(Locale.ROOT);
}
for (int i = 0; i < 100; i++) {
imgName = UUID.randomUUID().toString().substring(0, 8) + ext;
path = pathName + imgName;
floorFile = new File(path);
if (floorFile.exists()) continue;
else break;
}
FileOutputStream fos = new FileOutputStream(floorFile);
fos.write(fileData);
fos.close();
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传失败");
}
return imgName;
}
/**
* 解压缩 ZIP 文件到目标目录(覆盖同名文件,防 zip-slip 路径穿越)。
*
* @param zipFile 压缩包
* @param destDir 目标目录
*/
public static void zipDecompress(File zipFile, File destDir) {
byte[] buffer = new byte[1024];
try (ZipInputStream zis = new ZipInputStream(new FileInputStream(zipFile))) {
ZipEntry entry = zis.getNextEntry();
while (entry != null) {
File file = new File(destDir, entry.getName());
String destPath = destDir.getCanonicalPath();
String childPath = file.getCanonicalPath();
if (!childPath.startsWith(destPath + File.separator)) {
throw new BizException(ErrorCode.CUSTOM, "压缩包内存在非法路径");
}
if (entry.isDirectory()) {
file.mkdirs();
} else {
File parent = file.getParentFile();
if (!parent.exists()) {
parent.mkdirs();
}
try (FileOutputStream fos = new FileOutputStream(file)) {
int len;
while ((len = zis.read(buffer)) > 0) {
fos.write(buffer, 0, len);
}
}
}
entry = zis.getNextEntry();
}
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "解压失败");
}
}
/**
* 压缩文件/文件夹为 ZIP。
*
* @param sourceFile 被压缩文件/文件夹
* @param zipFile Zip 文件
*/
public static void zipCompress(File sourceFile, File zipFile) {
try (ZipOutputStream zos = new ZipOutputStream(new FileOutputStream(zipFile))) {
zos.setMethod(ZipOutputStream.DEFLATED);
zos.setLevel(Deflater.BEST_COMPRESSION);
if (sourceFile.exists() && sourceFile.isDirectory() && sourceFile.listFiles() != null) {
Arrays.stream(sourceFile.listFiles()).forEach(file -> addZipFile(file, zos));
} else {
addZipFile(sourceFile, zos);
}
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "压缩失败");
}
}
/**
* 向 ZIP 中添加单个文件(zipCompress 辅助)。
*/
private static void addZipFile(File file, ZipOutputStream zos) {
if (!file.exists() || file.isDirectory()) {
throw new RuntimeException("文件不存在或该文件为文件夹,请检查");
}
try (FileInputStream fis = new FileInputStream(file)) {
ZipEntry entry = new ZipEntry(file.getName());
entry.setMethod(ZipEntry.DEFLATED);
entry.setSize(file.length());
entry.setCreationTime(FileTime.from(Instant.now()));
entry.setLastAccessTime(FileTime.from(Instant.now()));
entry.setLastModifiedTime(FileTime.from(Instant.now()));
zos.putNextEntry(entry);
byte[] buf = new byte[1024];
int len;
while ((len = fis.read(buf)) > 0) {
zos.write(buf, 0, len);
}
zos.closeEntry();
} catch (IOException e) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "压缩失败");
}
}
/**
* 删除文件夹(含内容)。
*/
public static void delFolder(String folderPath) {
delAllFile(folderPath);
new File(folderPath).delete();
}
/**
* 删除指定文件夹下所有文件(保留目录本身)。
*/
public static boolean delAllFile(String path) {
boolean flag = false;
File file = new File(path);
if (!file.exists() || !file.isDirectory()) {
return flag;
}
String[] tempList = file.list();
if (tempList == null) {
return flag;
}
for (String s : tempList) {
File temp = new File(path, s);
if (temp.isFile()) {
temp.delete();
}
if (temp.isDirectory()) {
delAllFile(temp.getPath());
delFolder(temp.getPath());
flag = true;
}
}
return flag;
}
}
@@ -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;
}
}
+77 -9
View File
@@ -30,10 +30,17 @@ spring:
hibernate:
jdbc:
time_zone: Asia/Shanghai
servlet:
multipart:
# 模板上传(zip/rar)等大文件支持,对齐 Nacos prod 配置
max-file-size: 50MB
max-request-size: 200MB
redis:
host: localhost
port: 6379
database: 0
# 本地 Redis 开启了 requirepass(联调环境约定);健康检查据此转绿
password: 123456
# ===== 基础平台 yh 配置(遵循 yh-* spring-boot-starter 约定,具体 key 以对应 starter 版本为准)=====
yh:
@@ -41,7 +48,9 @@ yh:
enabled: true
sso:
# CAS 单点登录(浏览器 SSO 重定向 / ticket 校验)
server-url-prefix: http://sso.yunhaixingtong.com/cas/
# 本地浏览器联调:登录页=SSO 前端 /login(读取 service 参数回跳),票据校验=根路径
# /serviceValidate(160 实测;/cas/ 为存量本地默认值,160 网关已无此路径)
server-url-prefix: http://sso.yunhaixingtong.com/
# 本地 context-path=/、端口 8070;prod 由 Nacos 的 yh.sso.client-host-url 覆盖
client-host-url: http://localhost:8070
allow-headers: all
@@ -55,24 +64,83 @@ yh:
app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86
open:
# yh-openapi 开放接口客户端(前缀必须是 yh.open,OpenApiProperties 绑定;base-host + base-path 拼出
# 完整跨仓基址,openapi 客户端再拼 /oapi/** 端点;app-key/app-secret 同时驱动应用间签名)
enabled: true
# yh-openapi 服务目录:userbase/userclass/usermsg/flow-center 枚举兜底 = base-host + 默认 ctx;
# 身份密钥唯一来源 = yh.app-auth(本文件 app-auth 段),open.* 副本已删
base-host: http://base.yunhaixingtong.com
base-path: /userbase
app-key: 100001
app-secret: ec4e860206374c80938719fff8e14a86
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,数据源见
# config/PropertyEnvConfigSource:聚合 starter 自动装配,有 bean 即注册端点)。
# 本地无需配值(前端 dev 走 .env.dev);线上值经环境变量 YH_ENV_CONFIG_SOURCE_FRONT
# 或 Nacos userhome.yaml 同名键 yh.env-config.source.front 注入(projman 配置管理维护),
# 未配置时端点返回 {},前端走同域推导兜底。如需本地联调可打开:
# env-config:
# source:
# front: '{"baseHost":"http://base.yunhaixingtong.com","ssoHost":"http://sso.yunhaixingtong.com","serviceHost":"http://localhost:8070","fileHost":"http://localhost:8070/upload/","statsRealyConf":[]}'
logging:
level:
com.yunhai.userhome: debug
# ===== 消息中心代理(§3.5 最小消息接入 + WS 实时推送)=====
userhome:
msgcenter:
# UserMsg 服务基址(IDHttp 出站自动签名,身份=yh.app-auth appKey=100001,
# UserMsg trusted-apps 已含)。空 = 未接入(代理降级空,铃铛维持原表现)
usermsg-base-url: http://localhost:8080
# 浏览器可达的 UserMsg WS 端点(实时推送)。空 = 不下发(前端维持手动刷新兜底);
# 生产经网关暴露:wss://msg.yunhaixingtong.com/usermsg/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 文案)
app-names:
100009: 通知公告
100617: 通知公告
100019: 流程中心
100001: 用户主页
# 跳转基址唯一来源 = 应用注册表(UserBase /oapi/app/by-code|by-key 取 pc_url,§3.2 归一化后
# 拼 "#" + jumpPath);已是绝对 URL 的存量消息直接透传。手工基址表 jump-bases 已退役(C2),
# 生产 Nacos userhome.yaml 若残留 jump-bases 段应一并清理(Spring 静默忽略,留着误导)。
# [LOADTEST-METRICS-BEGIN] 临时埋点:健康探活 + prometheus 指标端点(方案 §3.3)。
# Dockerfile 一直在探 /actuator/health,本段同时修复了探活 404 隐患;
# show-details=never 不外泄 DB/Redis 细节,仅返回总体状态。
# 压测结束后:保留 health(生产探活需要),按《埋点移除清单.md》评估是否去掉 prometheus。
management:
endpoints:
web:
exposure:
include: health,prometheus
endpoint:
health:
show-details: never
# [LOADTEST-METRICS-END]
# ===== 文件上传与学校配置 =====
# 文件存储根目录(UserSettingsController 壁纸/图标上传使用)
# 本地默认 ./upload/(相对工作目录);生产由 Nacos 覆盖为 /data/upload(容器卷挂载到宿主机)
FILE_PATH: ./upload/
FILE_PATH: D:/bsch/userhome/upload/
# 文件 Web 访问前缀(拼接壁纸完整 URL 用,见 UserSettingsController.getUserWallpaperList)
# 本地 http://localhost:8070/upload/;生产由 Nacos 覆盖为 http://home.yunhaixingtong.com/userhome/upload/
FILE_WEB_PATH: http://localhost:8070/upload/
# 学校配置接口基址(SchSettingsController 使用,prod 由 Nacos 覆盖)
SCH_CONF_URL: ""
@@ -0,0 +1,25 @@
-- =============================================================================
-- [LOADTEST-METRICS] 临时埋点表 —— 《压测与oapi埋点统计方案.md》阶段二
-- 压测结束后移除:DROP TABLE(见文末);代码侧删除 loadtestmetrics 包。
-- 清单见仓库根《埋点移除清单.md》。
-- =============================================================================
-- 本地 dev 由 Hibernate(ddl-auto=update) 依据 ApiMetricsEntity 自动建表;
-- 生产 / 独立压测环境由 DBA 手工执行本脚本(与实体定义保持一致)。
CREATE TABLE IF NOT EXISTS api_metrics (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
stat_time DATETIME NOT NULL COMMENT '统计切片时间(60s 窗口结束时刻)',
uri VARCHAR(200) NOT NULL COMMENT '入站=本地路径,出站=oapi path(均去 query、数字段归一为 {id})',
method VARCHAR(10) NOT NULL COMMENT 'GET/POST/PUT/DELETE',
direction VARCHAR(8) NOT NULL COMMENT 'in=入站请求,out=出站 UserBase oapi 调用',
cnt BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内请求次数',
total_cost BIGINT NOT NULL DEFAULT 0 COMMENT '窗口内耗时累计 ms',
err_cnt BIGINT NOT NULL DEFAULT 0 COMMENT '入站=HTTP 非 200;出站=调用抛异常',
is_load_test TINYINT(1) NOT NULL DEFAULT 0 COMMENT '压测流量单列(入站 X-Load-Test: 1,出站继承)',
KEY idx_time (stat_time),
KEY idx_direction_time (direction, stat_time)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = '[LOADTEST-METRICS] UserHome 入站/出站频次统计(临时,压测结束后删除)';
-- 压测结束、热度报告导出后执行:
-- DROP TABLE IF EXISTS api_metrics;
@@ -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());
}
}
@@ -0,0 +1,838 @@
package com.yunhai.userhome.service;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yunhai.platform.openapi.core.OpenApiException;
import com.yunhai.platform.openapi.core.TargetSpec;
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.entity.UserCurrentRole;
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.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.assertNull;
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.contains;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
/**
* 消息中心代理单测(§3.5,BP-R3-1):冻结 UserMsg oapi 契约
* (list 信封 {total,content[]} / unreadCount 裸数字 / todo list 数组)、
* jumpUrl 改写规则与 showJump 判权出参(C1/C2 收口后口径)。
* <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>
* 出站通道 2026-09-20 起由 IDHttp 换 YschOpenCall(泛化签名直调,Tier 0):
* 160 事故根因 = IDHttp 共享签名拦截器只剥本应用 base-path,目标带 ctx(/usermsg)时
* 签名路径多一层前缀 → UserMsg AppAuthFilter 401。回归断言:TargetSpec 从
* 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 - SDK 类型化读取、学校口径下行与 jumpUrl/showJump 改写")
class MsgCenterProxyServiceTest {
/** 160 生产形态的 UserMsg 直连地址(含目标 context-path) */
private static final String PROD_BASE_URL = "http://usermsg-service:10009/usermsg";
private static final ObjectMapper MAPPER = new ObjectMapper();
private YschOpenCall openCall;
private IUserCurrentRoleService userCurrentRoleService;
private MsgCenterProperties properties;
private MsgCenterProxyService service;
@BeforeEach
void setUp() {
openCall = mock(YschOpenCall.class);
userCurrentRoleService = mock(IUserCurrentRoleService.class);
properties = new MsgCenterProperties();
properties.setUsermsgBaseUrl(PROD_BASE_URL);
service = new MsgCenterProxyService(openCall, properties, userCurrentRoleService);
}
private static JsonNode json(String s) throws Exception {
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
void list_targetSpecStripsUsermsgCtx_forPerTargetSign() {
// 160 回归:签名剥离的载体是 TargetSpec.basePath,必须等于 /usermsg
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null);
service.list(201, 1, 10, null, null, null, null, null, null);
ArgumentCaptor<TargetSpec> spec = ArgumentCaptor.forClass(TargetSpec.class);
verify(openCall).get(spec.capture(), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class));
assertEquals("http://usermsg-service:10009", spec.getValue().getBaseHost());
assertEquals("/usermsg", spec.getValue().getBasePath(), "basePath=目标 ctx,AppSignInterceptor 据此剥离后签名");
}
@Test
void list_buildsQueryParams_andRewritesJumpUrl() throws Exception {
properties.getAppNames().put("100617", "通知公告");
// 自发消息无 jumpApp → 按发送方 appKey 走 by-key 注册表(严格二选一)
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,"
+ "\"jumpType\":1,\"jumpPath\":\"/task/123\",\"appKey\":\"100019\",\"bizType\":\"flow.task\","
+ "\"bizId\":\"123\",\"sendTime\":\"2026-09-13 10:00:00\",\"isRead\":0},"
+ "{\"msgId\":2,\"title\":\"无跳转\",\"content\":\"系统通知\",\"msgType\":0,"
+ "\"jumpType\":0,\"jumpPath\":null,\"bizType\":null,\"isRead\":1}]}", MsgListPage.class));
Map<String, Object> out = service.list(201, 2, 20, Boolean.TRUE, null, null, 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("201", query.getValue().get("userId"));
assertEquals("2", query.getValue().get("pageIndex"));
assertEquals("20", query.getValue().get("pageSize"));
assertEquals("true", query.getValue().get("onlyUnread"));
assertFalse(query.getValue().containsKey("schId"), "schId=null 不下发(向后兼容,验收 7)");
assertEquals(2, out.get("total"));
java.util.List<?> rows = (java.util.List<?>) out.get("list");
Map<?, ?> first = (Map<?, ?>) rows.get(0);
assertEquals("http://localhost:8088#/task/123", first.get("jumpUrl"), "注册表 pc_url + # + 裸路径");
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 不补应用名");
Map<?, ?> second = (Map<?, ?>) rows.get(1);
assertNull(second.get("jumpUrl"), "非应用内跳转不改写");
assertEquals(Boolean.FALSE, second.get("showJump"));
}
@Test
void list_defaultsPageParams() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(null);
service.list(201, 0, 999, null, null, null, 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("1", query.getValue().get("pageIndex"), "非法页码回退 1");
assertEquals("100", query.getValue().get("pageSize"), "页长上限 100");
assertEquals("false", query.getValue().get("onlyUnread"));
}
@Test
void list_appNameMappedFromAppKey() throws Exception {
properties.getAppNames().put("100617", "通知公告");
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenReturn(dto("{\"total\":1,\"content\":[{\"msgId\":1,\"title\":\"通知公告\","
+ "\"content\":\"您有一条通知\",\"msgType\":0,\"appKey\":\"100617\",\"isRead\":0}]}",
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);
assertEquals("通知公告", first.get("appName"), "appKey 命中映射补应用名");
}
/** jumpApp 与 appKey 均空 → 注册表查址无源可查 → null(前端不渲染跳转),且无 by-code/by-key 出站。
* 应用字典(appId 翻译/行富化)2026-10-08 起每次 list 固定取一次,与查址无关。 */
@Test
void list_noRegistrySource_jumpUrlNull() 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\":\"/task/9\",\"bizType\":\"flow.task\",\"isRead\":0}]}", MsgListPage.class));
Map<?, ?> first = firstRow();
assertNull(first.get("jumpUrl"), "无注册表查询键 → 不改写");
assertEquals(Boolean.FALSE, first.get("showJump"));
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
void list_userMsgDown_degradesToEmpty() {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/list"), anyMap(), eq(MsgListPage.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
Map<String, Object> out = service.list(201, 1, 10, null, null, null, null, null, null);
assertEquals(0, out.get("total"));
assertEquals(0, ((java.util.List<?>) out.get("list")).size());
}
@Test
void wsTicket_returnsUrlWithTicket() throws Exception {
properties.setUsermsgWsUrl("ws://base.yunhaixingtong.com/usermsg/ws/msg");
when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class),
eq(JsonNode.class)))
.thenReturn(json("{\"ticket\":\"abc123ticket\",\"expiresIn\":60}"));
String url = service.createWsTicket(201);
assertEquals("ws://base.yunhaixingtong.com/usermsg/ws/msg?ticket=abc123ticket", url);
}
@Test
void wsTicket_wsUrlNotConfigured_nullWithoutOutbound() {
properties.setUsermsgWsUrl("");
assertNull(service.createWsTicket(201));
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
}
@Test
void wsTicket_userMsgDown_degradesNull() {
properties.setUsermsgWsUrl("ws://localhost:8080/ws/msg");
when(openCall.post(any(TargetSpec.class), eq("/oapi/msg/ws/ticket"), anyMap(), any(Map.class),
eq(JsonNode.class)))
.thenThrow(new OpenApiException("OpenAPI request failed: httpStatus=401"));
assertNull(service.createWsTicket(201), "票据失败降级 null,前端不建立长连接");
}
@Test
void summary_combinesUnreadAndTodos_withPerCallDegrade() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(3L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[{\"appKey\":\"100019\",\"count\":2}]"));
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(3L, out.get("unreadCount"));
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,待办仍可用
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenThrow(new OpenApiException("down"));
Map<String, Object> out2 = service.summary(201, null, 1);
assertEquals(0L, out2.get("unreadCount"));
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
void list_filtersPassedThrough() throws Exception {
stubAppDict(100019, "100019", "流程中心");
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, 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
void disabled_degradesWithoutOutbound() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
assertEquals(0, service.list(201, 1, 10, null, null, null, null, null, null).get("total"));
service.read(201, java.util.Collections.singletonList(7L), null);
verify(openCall, never()).get(any(TargetSpec.class), anyString(), anyMap(), any());
verify(openCall, never()).post(any(TargetSpec.class), anyString(), anyMap(), any(), any());
}
@Test
void disabled_fallsBackToDirectoryTarget_whenBaseUrlBlank() throws Exception {
// base-url 未配但服务目录有 usermsg(平台公共配置形态):按名调用仍可用
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(true);
when(openCall.get(eq("usermsg"), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(5L);
Map<String, Object> out = service.summary(201, null, null);
assertEquals(5L, out.get("unreadCount"));
}
@Test
void summary_disabled_degradesToZero() {
properties.setUsermsgBaseUrl("");
when(openCall.hasTarget("usermsg")).thenReturn(false);
Map<String, Object> out = service.summary(201, null, 1);
assertEquals(0L, out.get("unreadCount"));
assertEquals(0, ((java.util.List<?>) out.get("todos")).size());
}
@Test
void summary_todoQueryIncludesSchIdOnly_whenNotNull() throws Exception {
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/unreadCount"), anyMap(), eq(Long.class)))
.thenReturn(0L);
when(openCall.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), anyMap(), eq(JsonNode.class)))
.thenReturn(json("[]"));
service.summary(201, null, null);
service.summary(201, null, 3);
@SuppressWarnings("unchecked")
ArgumentCaptor<Map<String, String>> query = ArgumentCaptor.forClass(Map.class);
verify(openCall, org.mockito.Mockito.times(2))
.get(any(TargetSpec.class), eq("/oapi/msg/todo/list"), query.capture(), eq(JsonNode.class));
assertEquals(1, query.getAllValues().get(0).size(), "schId=null 不下发该参数");
assertEquals("3", query.getAllValues().get(1).get("schId"));
}
@Test
void malformedBaseUrl_disabledNotCrash() {
properties.setUsermsgBaseUrl("::::not-a-url");
Map<String, Object> out = service.summary(201, null, 1);
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);
}
}
-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<g clip-path="url(#clip0_1309_6469)">
<path d="M23.6935 22.1975H0.29991C0.13436 22.1975 0 22.0631 0 21.8976V21.2977C0 21.1322 0.13436 20.9978 0.29991 20.9978H2.39934V10.2002C2.39934 9.53796 2.93619 9.00052 3.599 9.00052H8.39769V6.60177C8.39769 5.93893 8.93455 5.40209 9.59737 5.40209H14.3961V3.00215C14.3961 2.33993 14.9329 1.80249 15.5957 1.80249H20.3944C21.0566 1.80249 21.5941 2.33993 21.5941 3.00215V20.9978H23.6935C23.8591 20.9978 23.9934 21.1322 23.9934 21.2977V21.8976C23.9934 22.0631 23.8591 22.1975 23.6935 22.1975ZM8.39769 10.2002H3.599V20.9978H8.39769V10.2002ZM14.3961 6.60118H9.59737V20.9978H14.3961V6.60118ZM20.3944 3.00277H15.5957V20.9978H20.3944V3.00277Z" />
</g>
<defs>
<clipPath id="clip0_1309_6469">
<rect width="24" height="24"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 886 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path d="M24.0476 21.4494H16.86V23.3254L18.288 24.8248V25.2H9.7102V24.8248L11.141 23.3268V21.4508H3.9506C3.8009 21.4525 3.65233 21.4246 3.51342 21.3687C3.37451 21.3129 3.24798 21.2302 3.14108 21.1254C3.03418 21.0206 2.94901 20.8957 2.89045 20.7579C2.83189 20.6201 2.80108 20.4721 2.7998 20.3224V5.32561C2.7998 4.70401 3.315 4.20001 3.9506 4.20001H24.0476C24.6846 4.20001 25.1998 4.70401 25.1998 5.32561V20.3252C25.198 20.4747 25.1667 20.6223 25.1078 20.7597C25.0489 20.8971 24.9636 21.0216 24.8566 21.126C24.7496 21.2304 24.6231 21.3127 24.4843 21.3682C24.3454 21.4236 24.1971 21.4513 24.0476 21.4494ZM23.664 5.69941H4.3342V17.1836H23.664V5.70081V5.69941Z" />
</svg>

Before

Width:  |  Height:  |  Size: 752 B

-3
View File
@@ -1,3 +0,0 @@
<svg width="28" height="28" viewBox="0 0 28 28" xmlns="http://www.w3.org/2000/svg">
<path d="M24.0476 21.4494H16.86V23.3254L18.288 24.8248V25.2H9.7102V24.8248L11.141 23.3268V21.4508H3.9506C3.8009 21.4525 3.65233 21.4246 3.51342 21.3687C3.37451 21.3129 3.24798 21.2302 3.14108 21.1254C3.03418 21.0206 2.94901 20.8957 2.89045 20.7579C2.83189 20.6201 2.80108 20.4721 2.7998 20.3224V5.32561C2.7998 4.70401 3.315 4.20001 3.9506 4.20001H24.0476C24.6846 4.20001 25.1998 4.70401 25.1998 5.32561V20.3252C25.198 20.4747 25.1667 20.6223 25.1078 20.7597C25.0489 20.8971 24.9636 21.0216 24.8566 21.126C24.7496 21.2304 24.6231 21.3127 24.4843 21.3682C24.3454 21.4236 24.1971 21.4513 24.0476 21.4494ZM23.664 5.69941H4.3342V17.1836H23.664V5.70081V5.69941Z" />
</svg>

Before

Width:  |  Height:  |  Size: 752 B

-10
View File
@@ -1,10 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" >
<g clip-path="url(#clip0_1309_6469)">
<path d="M23.6935 22.1975H0.29991C0.13436 22.1975 0 22.0631 0 21.8976V21.2977C0 21.1322 0.13436 20.9978 0.29991 20.9978H2.39934V10.2002C2.39934 9.53796 2.93619 9.00052 3.599 9.00052H8.39769V6.60177C8.39769 5.93893 8.93455 5.40209 9.59737 5.40209H14.3961V3.00215C14.3961 2.33993 14.9329 1.80249 15.5957 1.80249H20.3944C21.0566 1.80249 21.5941 2.33993 21.5941 3.00215V20.9978H23.6935C23.8591 20.9978 23.9934 21.1322 23.9934 21.2977V21.8976C23.9934 22.0631 23.8591 22.1975 23.6935 22.1975ZM8.39769 10.2002H3.599V20.9978H8.39769V10.2002ZM14.3961 6.60118H9.59737V20.9978H14.3961V6.60118ZM20.3944 3.00277H15.5957V20.9978H20.3944V3.00277Z" />
</g>
<defs>
<clipPath id="clip0_1309_6469">
<rect width="24" height="24"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 886 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 445 KiB