43 Commits
Author SHA1 Message Date
ops 3cfad02a43 fix(ci): 构建容器名唯一化 + npm 走国内镜像源(避免并发互踩 exit 137) 2026-10-21 06:24:32 +00:00
ops ea14dc6f82 ci: apk 源统一走 aliyun(规避 dl-cdn 时段性被拦卡 60s) 2026-10-21 01:37:04 +00:00
chenxing ed0c752f4a refactor(oapi): 提供方强制验签迁 starter——enforce-patterns+trust-registry(审计 P1-6 / L2-8)
build / build-push (push) Successful in 1m25s
- 删自写 svcitem/config/OpenApiAuthEnforceConfiguration(yh-app-auth starter 版
  RequireAppSignFilter 承接,order+11 行为等价;/oapi/site/list 面无签名即 401)
- yh.app-auth.server.enforce-patterns=[/oapi/*] + trust-registry=true(中央密钥链组合:
  trusted-apps 100018 先、Redis sign:app:* 链后,Notice/flow-center 同款)
- data-redis 自 test scope 提升编译期(yh-app-auth 侧 optional 不传递,生产 classpath
  此前并无该依赖);management.health.redis.enabled=false 防 Redis 抖动拉 DOWN 健康
- 测试 yml 同步 enforce-patterns:删自写副本后 SiteOapiIT 的 /oapi/** 401 断言由
  starter 过滤器承接;trust-registry 不进测试配置(IT 密封性,签名用例 100018 走
  trusted-apps 命中)

门禁:mvn test -Dskip.tests=false 413/413 绿(含 SiteOapiIT 6/6,本机 MySQL8+Redis)
2026-10-05 22:26:44 +08:00
chenxing 55d80e21a4 feat(oapi): /oapi/site/list 下发站点主题 theme+themeParams(T1)——SiteOptionVo 扩两字段 raw 透传(theme 空=default、themeParams=config_json.theme_params 原样键值、未配=空对象),ExtJsonUtils 解析对齐 PortalSiteSnapshot 风格;SiteOapiIT 冻结新契约(demo1 campus-glow、demo2 兜底形态) 2026-10-03 13:43:36 +08:00
chenxing 84224b31bb feat(portal): 我要办理深链带 from 回跳参数(T4)——新增 SvcHallLinks 纯函数构造器(base 尾斜杠裁剪+UTF-8 urlencode+from 缺省不追加),PortalController 自动生成分支改走它并补 from=指南页绝对 URL(hall 显示返回门户);urlEncodeValue 私有方法随唯一调用点替换删除;ServicePortalIT 断言同步新契约 2026-10-03 13:26:30 +08:00
chenxing f591d06c35 refactor(svcitem): 目录键归一 svchall→svc-hall(D1)——服务目录键/legacy 私有键 yh.svchall.base-url→yh.svc-hall.base-url(值域=部署名锚点),javadoc/测试/测试 yml 同步;Java 包名 com.yunhai.svchall 按惯例保留
build / build-push (push) Successful in 1m18s
2026-09-28 17:32:31 +08:00
chenxing da39bb5066 [CMS-VISUAL] D-8 补提交: 三保留主题 home.html 挂载骨架共享动效脚本(animation=off 可关)
build / build-push (push) Successful in 1m0s
2026-09-28 15:16:26 +08:00
chenxing fbcf783d3b [CMS-VISUAL] R-45 形状语言补强: stats_band 计数环 conic-gradient 装饰(设计卡 ring-gradient 语言落点) 2026-09-28 15:13:18 +08:00
chenxing a9e4240bb3 [CMS-VISUAL] E 激进清场: 下架 10 套伪主题(tag themes-before-cleanup-2026-09 + docs/主题备份/ 双备份)→ 保留 default/scholar-blue/campus-glow 三套;色皮档并入 default.colorScheme、克隆档并入 scholar-blue.colorScheme(ThemeParam.presets + ThemeVarsCss 变量组注入);别名表 R-46 映射(渲染端不断渲染 + 保存归一化 + 站点级 theme_params.colorScheme) 2026-09-28 14:58:31 +08:00
chenxing efd3cb30f9 [CMS-VISUAL] D 测试: PortalDComponentsIT 5 用例(骨架渲染/topic/event/空态 R-44/日期排序/形状参数)+ PersonServiceIT 4 + HomeLayout 校验 2 + purgeTables 清 cms_person 2026-09-28 14:04:38 +08:00
chenxing 4cb435b715 [CMS-VISUAL] D1-D10 后端: 新组件数据槽(banner 扩 topic/event 位 5 列 + cms_person 新表+CRUD+防跨站)+ 6 区块类型校验与装配(空数据整块不渲染 R-44)+ 骨架新组件 fragment/内置 22 图标集/共享动效 JS(reveal+count-up R-42)+ searchStyle 搜索浮层(D-10,含坑④条件包裹修复)+ 三保留主题 4 新参数 2026-09-28 14:04:19 +08:00
chenxing 089cb54011 [CMS-VISUAL] F2: 门户布局区块输出稳定 id + data-plb-id 锚点(display:contents 零布局影响)+ postMessage 预览监听脚本(门户侧 50 测试绿) 2026-09-28 12:52:45 +08:00
chenxing c60f9dffb7 [CMS-VISUAL] F3: 首页展示形态唯一入口收敛到布局编辑器——栏目保存缺省 homeStyle 时保留存量值,管理弹窗移除该下拉(19/19 IT 绿) 2026-09-28 12:32:34 +08:00
chenxing 19e9c18c96 feat(theme): S7 炫彩校园主题补齐——3 形态自研+2 新区块+svc/guide 页族,A 档三包整项自审收口
build / build-push (push) Successful in 49s
- fragments/home-blocks.html 150→280(纯追加 130 行):news_grid/media_focus/image_wall 三形态
  由委派 common::plb* 改为本包自持,并补 quick_links/notice_bar;空态用内联 div.cg-empty
  (与 scholar-blue/mint-breeze 的 ::empty 子片段策略分裂,记 R-37 未统一)
- home.html 分派改双层 th:switch(case 父 / replace 子,坑④)并引入 common :: style(R-32 本包实例关闭)
- assets/css/main.css 402→656(纯追加 254 行,0 删):新节全部 .cg-svc-*/.cg-chip/.cg-tag/.cg-sec-card
  等全新类名,未重写任何既有选择器;坑③ 在本包论证不适用(.cg-home 非网格,全幅 section 天然占满)
- 新建 service.html(135)/guide.html(134):poly-blue 页族换皮,绑定面 diff 0 差异
- theme.json 零改动(P-A3 步骤 6「不动参数」,实测 9 参保持)
- 后端全量 412/412 + 前端 160/160 绿;本次 S10 复跑后端 412、前端 166 绿
2026-09-22 21:54:35 +08:00
chenxing 854204d1c0 feat(theme): S6 薄荷沁风主题改造——自研 home-blocks 10 形态+2 新区块、svc/guide 页、三列网格区块显式跨行
build / build-push (push) Successful in 55s
- fragments/home-blocks.html 新建 238 行 11 fragment(8 形态 + quick_links/notice_bar + empty),
  坑①空态守卫写在外层 th:block、坑②新增可选键一律 block['key'] 括号访问
- home.html 双层 th:switch(case 在父、replace 在子,坑④),未知类型仍回落 common :: render
- assets/css/main.css 617→1123 行:.home-main 三列网格下每个区块根类显式 grid-column:1/-1(坑③最高风险点)
- service.html/guide.html 新建(poly-blue 平移换皮,绑定面零差异);theme.json params 9→10 新增 headerStyle
- fragments/header.html 1 行接 themeParams['headerStyle']
- 其余 10 套包零改动;后端全量 412/412 绿(skip.tests=false,3307 侧车库)
2026-09-22 21:53:43 +08:00
chenxing bc794ccce3 feat(theme): S5 学院蓝主题改造——home-blocks/service/guide页+区块CSS+ThemeRegistry增强
build / build-push (push) Successful in 53s
2026-09-22 17:35:38 +08:00
chenxing 82d6ff4602 feat(theme): 新增三套高校官网风皮肤 poly-blue/agri-forest/jade-normal
build / build-push (push) Successful in 51s
以 default 为基座(文件最全:含事项库 service/guide 两模板),设计语言与配色取自
真实高校官网 CSS(浙理工 #0558A5+橙、信阳农林 #1C6E19+麦穗金、信阳师大 #008D3F+暖橙)。
三包均自带九段 home-blocks、:root 契约变量别名、中文防塌列三件套、真实实拍截图
(1920 无头截图缩样)。

- UniversitySkinContractTest(7 用例):九段 fragment/homeStyle 分发/防塌列/变量别名/
  层序/自有截图/主色色域(蓝或绿通道主导,粉紫禁入)
- ThemeRegistryTest +哨兵:三包注册成功且主色与 default/scholar-blue 及彼此互异
- jade-normal 修复:news_grid 全宽封面变体把正文列压成 0 宽致 390px 溢出 47px,
  增 .jn-feat-a--stack 纵向变体,复测 overflowX=0
- 门禁:ThemeContractIT 14 主题×7 路由全绿;全量 mvn test 393/393 绿
2026-09-20 02:31:53 +08:00
chenxing d2df12b91e feat(theme)!: 下线粉/紫两包,改以学院蓝为基座交付 academic-navy/campus-azure 两套高校官网风皮肤
build / build-push (push) Successful in 51s
线上 10027 首页配了 home_layout 后走 themes/common 通用骨架,派生皮肤只换了页头页脚,
正文仍是骨架内置蓝灰卡(外加 cherry-bloom 的粉紫配色),用户判定"完全不能用"。

- 新增 academic-navy(学府藏蓝,an-)/campus-azure(青蓝校园,cz-):以 scholar-blue 为基座,
  各自自带 fragments/home-blocks.html 与 home.html 分发,news_grid/media_focus/compact/
  image_wall/card/ticker/gallery/links 八种区块形态全部由皮肤渲染,未知类型才回落通用骨架
- 两套皮肤 :root 把私有变量别名到 --primary/--text/--card-bg/--border/--radius 契约变量;
  中文行锁 nowrap+min-width:0+ellipsis,grid 轨道用 minmax(0,1fr) 防窄视口压塌
- 新闻背景带 .an-sec-bg/.cz-sec-bg 压 z-index:0、卡片抬到 1:绝对定位层默认画在普通流之上,
  不抬起会整张卡片泛成底图色(无头截图实测)
- common 骨架空态守卫改包 th:block(th:replace 优先级 100 先于 th:if 300)
- 删除 cherry-bloom/violet-iris;sql/upgrade_theme_academic_skins.sql 幂等改指存量站点
  (站点缓存 CacheNames.SITE TTL 30 分钟,改库后需重启或在管理端重存站点)
- 测试:AcademicSkinContractTest(区块自带/分发/CJK 行/变量别名/主色必须学术蓝/背景带层级/
  预览图不得沿用基座)、HomeBlocksCssContractTest、PortalHomeLayoutIT 端到端断言零 plb- 输出;
  全量 383 项通过
2026-09-19 20:21:30 +08:00
chenxing 52433f06a2 fix(theme): 首页/列表移动端 1fr 网格改 minmax(0,1fr) 消除窄视口横向溢出;新包落位真实截图与 favicon
build / build-push (push) Successful in 50s
2026-09-19 17:47:41 +08:00
chenxing 085c284829 feat(theme): 新增 cherry-bloom/violet-iris 两套门户皮肤并应用于视觉种子院系子站 2026-09-19 17:18:19 +08:00
chenxing c44c35bf2a fix(portal): 错误页返回首页链接改 th:href=@{/portal/} 感知 context-path(R-04) 2026-09-19 17:18:10 +08:00
chenxing 8839ef8a5f feat(site): /oapi/site/list 按校查询只下发主站——院系子站不接入办事大厅
build / build-push (push) Successful in 50s
- 带 schId 过滤 parentSiteId=0(办事大厅按校接入,一校一主站,SiteOapiIT 冻结契约)
- 不传 schId 全量仍含子站:svc-hall resolveSite 反解 siteCode→schId 依赖,历史办件可继续定位
2026-09-17 14:51:07 +08:00
chenxing 89a5a346d3 fix(portal): 服务中心/指南页顶导航高亮——外链栏目匹配为当前栏目,首页不再误高亮(8 主题兜底 navServiceActive)
build / build-push (push) Successful in 1m43s
2026-09-17 01:10:14 +08:00
chenxing 649ad66264 refactor(sdk)!: CMS→svc-hall 出站改走 YschOpenCall 命名目标 + 适配 SDK 领域分包
build / build-push (push) Successful in 1m36s
- SvcHallClient:由手搓 IDHttp + yh.svchall.base-url 拼 URL 改为 YschOpenCall 命名目标
  svchall(服务目录 yh.open.services.svchall)。原形态对带 context-path 的 svc-hall
  必 401(共享拦截器只剥 yh.open.base-path=/userbase);per-target base-path 与提供方
  ctx 对齐后签名天然一致。目录未配时回落 legacy 键;两者皆缺则构造期 fail-fast。
- SvcItemRemoteQuery:新增 businessAbsent 判定,统一两条出站路径的「业务 code=2 →
  404 哨兵/空集,其余上抛」语义(目录路径抛 DException,legacy 抛 DHttpException)。
- import 迁移:YschBaseOpenApi → openapi.base.*(SDK 领域分包)。
- application.yml:新增 yh.open.services.svchall,删 yh.svchall.base-url。
- 测试:新增 SvcHallClientTest(8 例:目录目标/参数映射/空参不下发/fire-and-forget/
  回落/fail-fast);SvcItemRemoteQueryTest 补目录路径错误语义 4 例。104 测试全绿。
2026-09-16 21:05:55 +08:00
chenxing 1cb95225a6 周末修订
build / build-push (push) Successful in 3m59s
2026-09-15 23:41:34 +08:00
chenxing e4b843cf00 refactor(base): GetUserMod 改为消费用户中心下发的模块树;默认关闭测试执行
build / build-push (push) Successful in 1m9s
两处独立改动:

1) CmsUserCenterService:用户中心已按平台契约 BoaModule.children 下发模块树,本仓原先
   按 parentId 重建父子关系的 buildModuleTree 成为空转(与 Notice/svc-hall 同构的重复实现)。
   现收敛为 toFrontendTree,只做前端 layout 需要的字段改名(path→page、id→modId、补
   usable/orderNumber)并递归映射 children;层级与兄弟顺序以上游 customId 序为准,本侧不
   重排。前端契约不变(children 恒为数组);拿到旧版用户中心的扁平结果(children=null)时
   退化为与今日一致的扁平菜单,不抛 NPE。保留原有平台不可达时的空菜单降级。

2) pom:新增属性 skip.tests(默认 true),surefire 以 <skipTests>${skip.tests}</skipTests>
   消费——测试执行默认关闭。原因:本工程 *IT 集成用例连本地 MySQL/Redis,缺中间件时成片报
   Communications link failure / 上下文加载失败(353 例中约 262 例),噪音掩盖真信号,且
   CI 构建本就是 mvn -DskipTests package,本地空跑约 20 分钟。只跳「执行」,测试源码仍编译,
   接口漂移仍可在编译期发现;需要跑时用 mvn test -Dskip.tests=false 临时打开。
2026-09-15 18:43:24 +08:00
chenxing 9e28092a8b feat(svcitem): 事项库+办事大厅 R2 后端落地,并完成交付后独立审计修复
build / build-push (push) Successful in 2m26s
落地(依据《事项库与办事大厅详细设计 v1》)
- 事项域垂直包 com.yunhai.cms.svcitem.*(entity/enums/repository/service+impl/event/config/model dto+query+vo)
  与 controller/{api,oapi}/svcitem;两表 DDL 进 cms_init.sql(表 7/8)+ upgrade_svcitem_r2.sql
- SvcItemQuery 接缝:门户 8 方法 + 管理端 11 方法统一收口,控制器不再注入实现类
- 门户公开层:doServiceList/doGuide 双路由变体 + M0 可选模板整页回退
  (视图名/themeBase/buildUrl/资产主题段四处同源 effectiveTheme,缺模板整包退 default)
- oapi 跨服务快照:与平台 AppAuthFilter 同口径签名(canonical=method\npath?sortedQuery\ntimestamp,
  HmacSHA256/Base64, 5min 窗口)+ /oapi/* 强制验签过滤器
- default 皮肤 service.html/guide.html + main.css 服务中心/指南页样式段

审计修复(发现与处置全表见 docs/18-事项库审计与修复记录.md)
- D1 附件引用链:内容资产(封面+正文内嵌图)独立场景 svc_item_asset,保存时按内容派生全量同步归属
  (AttachmentService.localAssetUrls/localWebUrl/syncAssets:集合内标记、集合外解除)。
  修复根因:upload 带 scene 时只写 ref_type 不写 ref_id,而保存仅按前端提交 id 重绑,
  正文图与封面停在半绑定态 → 既不在孤儿口径内也不受 release 管辖,成永久无主行
- D5 归属校验:绑定前做可认领判定(无引用/半绑定/已属本事项方可绑),他事项引用与跨站附件跳过并告警
- D2 部门:组类型改配置 cms.svc.item.dept-group-type(默认 2=学校组,校内科室改 3);
  deptTree 返回嵌套组树;新增 SvcDeptSource 接缝使静态 getGroupTree 可替身(此前部门链零覆盖)
- D4 缓存:事件 scope 语义化,dict/dept 变更整体失效 cms:svcItem(展示名快照不再滞后 5min)
- 站点解析:findByCode 在 uk(parent_site_id,code) 下命中多条会抛异常变 500,
  4 处生产调用点改 findFirstByCodeOrderByIdAsc(确定性取最早站点)
- 加固:新增 idx_svc_item_portal(含 DESC 列)对症门户排序 filesort;升级脚本改 information_schema
  +PREPARE 幂等索引守卫块(表在缺索引可重跑修复);clear_cms_data.sql 补事项两表;
  view_count 改有界队列+拒绝丢弃告警;SEL_LIST_MAX 截断告警

测试
- 新增 SvcDeptChainIT 7、SvcItemServiceIT 16(含 D1/D5 回归锚点与全方法权限)、
  SvcItemOapiIT 9(时间戳超窗/未知 appKey/畸形头/siteCode 歧义)、SvcItemPortalIT 11
  (嵌套指南路由/关键词/canonical/浏览量异步)、SvcDictServiceIT 8(改名后缓存即时刷新)
- ThemeContractIT 补"缺可选模板主题资产段必退 default"断言并订正七路由表述
- 全量 mvn -o test:388 测试 0 失败;升级脚本 bsch_cms_test 两跑幂等(含缺索引自动补建)
2026-09-11 14:12:04 +08:00
chenxing 39454e4efb feat(portal): docs/16 首页布局后端- home_layout零DDL消费与主题fragment化-PortalSiteSnapshot.homeLayout+normalizeHomeLayout脏数据容错;PortalPageRenderer.buildLayoutBlocks按区块序列渲染(article_list多栏目聚合+homeStyle别名+category合成+background回落news_bg_image/ticker top|category双源/gallery标题副题缺省旧键/links分组过滤/visible跳过/未知type跳过/size 1~20夹取);布局模式清空topArticles/focusArticles/categoryBlocks/galleryBanners回落段;SiteService.validateConfigJson带siteId收紧home_layout(blocks<=30/type白名单/size 1~20/文本<=100/categoryIds归属校验:新建拒跨站拒不存在拒);themes/common/fragments/home-blocks.html plb-*兜底骨架(不注册皮肤包,Thymeleaf classpath可解析);campus-glow自有fragment(news_grid/media_focus/card/ticker/gallery)原home.html抽出;default+blue-white+classic-red+fresh-mint+mint-breeze+warm-kinder+scholar-blue+campus-glow八套home.html接入布局分支(th:case父元素+th:replace子元素嵌套,replace优先级100先于case执行);新增PortalHomeLayoutIT 5+SiteServiceHomeLayoutIT 3,全量330绿
build / build-push (push) Successful in 52s
2026-09-10 15:08:40 +08:00
chenxing 98d640faba feat(portal): 门户左侧栏目导航+首页视觉升级+主题正名批次-PortalSideNav模型+attachCategoryNavigation注入doCategory/doArticle三件套categoryPath/navTopId/sideNav(站点快照树推导零新增查询,home/search不注入);7主题侧栏两栏markup+窄屏横滚+顶导航祖先高亮+二级下拉当前项高亮+面包屑全路径;cms_category.home_style全链路(news_grid 16条8x2日期前缀/media_focus形态)+PortalListData.Item补source来源徽标四处装配;Banner机制gallery展示位→galleryBanners四列大图轮播(自由字符串零DDL);页脚config 12新键(footer_addresses多校区/qr_*/social_*含URL校验,footerAddressLines由PortalSiteSnapshot.of拆行派生);新增展示型皮肤包campus-glow;fresh-mint2正名fresh-mint(theme.json/头注释同步);AttachmentService scene白名单加category(单页富文本贴图免清孤儿);新增PortalSideNavIT 8+PortalHomeBlocksIT 3,CategoryServiceIT/SiteServiceIT/PortalSsrIT/ThemeContractIT等单类独跑全绿;附sql/clear_cms_data.sql全量清库脚本(清cms_*六表留平台账号/主题包/上传文件,测试数据重走用)
build / build-push (push) Successful in 1m47s
2026-09-10 11:10:48 +08:00
chenxing f4b1749377 refactor: 删除 PropertyEnvConfigSource,env-config 数据源由平台默认挂载文件源兜底(yh-env-config starter B2 下沉)
build / build-push (push) Successful in 1m52s
2026-09-09 11:27:39 +08:00
chenxing a666e13f56 feat(theme): 皮肤包管理收敛与命名批次-兜底catch经wrapFailure先log.error记原始堆栈再包装(upload/derive/updateMeta三处,extractZip/deleteRecursively静默吞改warn/debug);内置包改显示名走.meta覆盖层(ThemeRegistry加载后覆写title/description,updateMeta放开内置:全空=恢复默认,params拒改);derive/upload支持表单命名(theme.json改写regex换ObjectMapper防误替换,upload落位前改staging失败不落位);resolveTheme回退warn补站点编码;ThemeVo.editable语义改为可编辑元数据;附内置包mint-breeze/fresh-mint2与R1学校主站编码迁移SQL;308/308全绿
build / build-push (push) Successful in 48s
2026-09-08 20:25:43 +08:00
chenxing 12a492fc55 feat(env-config): 管理端运行时配置接入-PropertyEnvConfigSource数据源(yh.env-config.source.<target>,target=admin),免登录GET /api/env-config由聚合starter自动装配(旁路CAS),EnvConfigApiIT随机端口匿名200/未配置{}契约(兼平台starter回归哨兵),全量301/301
build / build-push (push) Successful in 1m17s
2026-09-08 17:18:17 +08:00
chenxing 338ffb56dd feat(W1): 皮肤包完整管理-外部主题目录+热注册,zip上传/派生/编辑/删除,ThemePackageValidator收敛,Thymeleaf文件解析器,list增source/inUseCount/editable/usedBySiteNames,T-W1-01..20 全绿299/299
build / build-push (push) Successful in 52s
2026-09-08 14:52:29 +08:00
chenxing 536bd6cea4 feat(W4): 院系子站嵌套路径-cms_site.path列+uk_site_path/parent_code,门户单段+嵌套双映射,旧链301,public_base_url派生,sitePath预览,SQL三处+升级脚本,T-W4-01..15 全绿279/279 2026-09-08 12:34:33 +08:00
chenxing 5da6f7ee5d fix(W3): 预览资产代理前缀拼 request.getContextPath(/yschcms 下 CSS 404 裸 HTML),T-W3-01..04 全绿+全量 264/264 2026-09-08 10:51:52 +08:00
chenxing 32448f097e docs(收尾): README 增接口变更声明-site/save 权限拆分/attachment scene/unusedCount/custom_css
build / build-push (push) Successful in 49s
2026-09-07 20:38:37 +08:00
chenxing a3bee60b61 feat(B4): 友链页脚 Logo 渲染-5主题 img+名称并排,渲染期补基准
- PortalLink 模型已有 logoUrl,渲染缺口在模板:5 套 footer 增 th:if logo 图(url.upload 补基准+alt 名称+lazy)
- 5 套 main.css 增 .link-logo(高20px/contain/垂直居中/过宽截断)
- PortalSsrIT+2 例(有 logo 渲染图/无 logo 纯文字无 link-logo);mvn -o test 260/260 全绿
2026-09-07 20:22:44 +08:00
chenxing 19e414ee81 feat(B3): custom_css 站点级自定义样式-服务端黑名单强校验+5主题head内联直出
- validateConfigJson 增 custom_css 分支:≤10240字符;黑名单忽略大小写拒 </style/<script/@import/expression(
- 快照/渲染链路(model customCss)与 themeVarsCss 同构注入;缓存失效复用既有 SiteContentChangedEvent
- 5 套主题 head.html 于 main.css 与微调变量之后 th:utext 直出(安全性完全依赖服务端先行)
- SiteServiceIT+6 例(含 expression 红线项与 10240 边界);PortalSsrIT+3 例(渲染/head内/顺序/缓存失效)
- mvn -o test 258 例全绿(B3 状态,不含 B4 红测)
2026-09-07 20:14:23 +08:00
chenxing b4822a7f38 feat(B2): 附件引用模型-cms_attachment 增 ref_type/ref_id,消灭孤儿误报
- 实体/双 SQL 加列+idx_att_ref;upload 增 scene 白名单(banner/link/site)
- AttachmentService 新增 markUsed/release/markSiteConfigUsed(webUrl 反查,幂等)
- 未使用=articleId 与 refType 均空;page 返回 unusedCount(前后端同步改名无兼容别名)
- Banner/Link 保存先解引用再回写(换图旧图回未使用);Site 配置 logo/favicon 引用回写(@Lazy 解循环依赖)
- 新增13例(Attachment6/Banner3/Link2/Site2)+既有语义断言更新;mvn -o test 全绿
2026-09-07 19:40:14 +08:00
chenxing 52676f592a feat(B1): 建站权限拆分-校级可建/编辑本院系子站,顶层站仍仅超管
- SiteService.save 入口换 requireSiteSavePermission 私有方法(安全边界单点):
  超管直通;无校级角色/顶层编辑/新建顶层/子站提升/跨校主站/伪造schId 全拒
- SiteServiceIT 新增10例(方案7例+伪造schId/跨校接管/同校改挂3个边界)
- mvn -o test 236/236 全绿(基线226+10)
2026-09-07 19:21:18 +08:00
chenxing dad5eedaf2 chore: 前置快照-logback-spring.xml(执行方案B1~B5前既有未提交变更) 2026-09-07 18:59:23 +08:00
chenxing eb5c3afaaf merge projman injected files
build / build-push (push) Successful in 47s
2026-09-07 09:10:24 +08:00
chenxing 99cd26b3d2 init local code 2026-09-07 09:09:39 +08:00
290 changed files with 33412 additions and 9 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: |
+10
View File
@@ -0,0 +1,10 @@
target/
*.class
*.log
logs/
.idea/
*.iml
.vscode/
.DS_Store
# 前端构建产物拷入目录(见 front README)
src/main/resources/static/admin/
+7
View File
@@ -0,0 +1,7 @@
# CMS 后端镜像(对齐平台 sub-app 打包约定:fat jar + JRE8)
FROM openjdk:8-jre-slim
WORKDIR /app
ENV TZ=Asia/Shanghai
COPY target/cms.jar /app/cms.jar
EXPOSE 8072
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /app/cms.jar"]
+10
View File
@@ -3,6 +3,16 @@
> 本仓库由 projman 自动创建并注入 CI(`.gitea/workflows/build.yml`)与 Dockerfile(`docker/Dockerfile`)。
> 推送代码到 main 分支即自动构建镜像并推送到 Nexus,projman 收到通知后建档待部署,由运维在 projman「后端发布」手动点部署。
## 接口变更声明(2026-09-07 产品优化批次 B1~B5)
| 接口 | 变更 | 兼容性 |
| --- | --- | --- |
| `POST /api/site/save` | 权限语义拆分:原"仅超管"放宽为——校级角色(currentRole.schId 非空)可新建/编辑**本校主站下的院系子站**(parentSiteId>0 且主站属本校);顶层站新建/编辑/子站提升为顶层仍仅超管;请求体显式 schId 与角色不一致直接拒绝(留空由服务端继承主站) | 校级为新增放行面;超管行为不变;越权统一 403 |
| `POST /api/attachment/upload` | 新增可选 `scene` 参数(banner/link/site 白名单),落 `ref_type` 作场景引用初步标记;不传保持原行为 | 纯新增可选参数,旧调用方不受影响 |
| `GET /api/attachment/page` | "孤儿"语义改为"未使用":`articleId IS NULL AND ref_type IS NULL`;返回键 `orphanCount` 更名 `unusedCount`(无兼容别名,前端已同步);`orphanOnly=true` 过滤同语义 | **破坏性**:消费 orphanCount 的旧前端需同步(本仓库 front 已于同批改造) |
| `cms_attachment` 表 | 新增 `ref_type VARCHAR(16)`、`ref_id INT` 与 `idx_att_ref` 索引(B2 附件引用模型);存量数据不回填,旧场景图在重新保存前显示"未使用"为已知差异 | 加列向后兼容;开发库 ddl-auto=update 自动加列,生产执行 `sql/cms_init.sql` 增量 |
| `config_json.custom_css` | 新增站点级自定义样式键:服务端强校验(≤10240 字符;黑名单 `</style`/`<script`/`@import`/`expression(` 忽略大小写);门户 5 套主题 head 内联直出 | 新增键,未配置站点不受影响 |
## 首次推送本地代码到本仓库
> 目标:把本地已有代码推上去,同时**保留** projman 注入的文件(`build.yml`、`Dockerfile`)。
+155
View File
@@ -0,0 +1,155 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.18</version>
<relativePath/>
</parent>
<groupId>com.yunhai</groupId>
<artifactId>cms</artifactId>
<version>0.1</version>
<name>cms</name>
<description>yh-platform 高校门户 CMS(管理端 API + Thymeleaf 门户 SSR)</description>
<properties>
<java.version>1.8</java.version>
<!-- 测试执行开关:默认关闭(*IT 依赖本地 MySQL/Redis,详见 build 内 surefire 注释);
需要跑测试时用 -Dskip.tests=false 临时打开 -->
<skip.tests>true</skip.tests>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-platform-dependencies</artifactId>
<version>1.0.0-SNAPSHOT</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- 门户 SSR(SEO/首屏刚需,方案 §3.1) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
<!-- 模板内 LocalDateTime 格式化(#temporals) -->
<dependency>
<groupId>org.thymeleaf.extras</groupId>
<artifactId>thymeleaf-extras-java8time</artifactId>
</dependency>
<!-- 1-2 校规模本地缓存;上量切 Redis 仅改配置(方案 §4.1/§8.4) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- 参数校验(@Valid + 平台 GlobalExceptionHandler 统一包装) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<!-- 基础平台全家桶(聚合 starter):CAS 认证 + 应用间签名 + 统一响应 + OpenAPI 客户端 +
actuator 健康探活,一行带齐(同 UserHome/Notice 约定) -->
<dependency>
<groupId>com.yunhai.platform</groupId>
<artifactId>yh-spring-boot-starter</artifactId>
</dependency>
<!-- CAS 客户端(UserHome 同款显式声明) -->
<dependency>
<groupId>org.jasig.cas.client</groupId>
<artifactId>cas-client-core</artifactId>
<version>3.5.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.36</version>
<optional>true</optional>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<version>8.0.33</version>
<scope>runtime</scope>
</dependency>
<!-- 富文本正文入库前白名单清洗(方案 §8.5 内容安全);平台内无现成实现,自引入 -->
<dependency>
<groupId>org.jsoup</groupId>
<artifactId>jsoup</artifactId>
<version>1.17.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- 入站验签走中央密钥链(注册表模式 yh.app-auth.trust-registry)必须编译期引入 data-redis:
yh-app-auth 的该依赖是 optional、不传递(Notice/flow-center/svc-hall 同款,2026-10-05 自
test scope 提升)。副作用是 RedisHealthIndicator 会被装配,故 yml 里关掉
management.health.redis.enabled,防 Redis 抖动把 /actuator/health 拉成 DOWN(503)。
RedisCacheEvolutionIT(缓存演进验证)继续复用本依赖。 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
</dependencies>
<build>
<finalName>cms</finalName>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
<!-- 本工程测试命名约定:纯单元 *Test + 集成 *IT(连本地 MySQL/Redis),统一由 surefire 执行 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<configuration>
<!--
测试执行默认关闭(2026-09-15):*IT 依赖本地 MySQL/Redis,缺中间件时成片报
Communications link failure / 上下文加载失败(353 例里约 262 例),噪音掩盖真信号;
CI 构建本就是 mvn -DskipTests package,关掉后本地 mvn test/package 不再空跑约 20 分钟。
只跳过「执行」,测试源码仍会编译,接口漂移仍能在编译期暴露。
需要跑时临时覆盖:mvn test -Dskip.tests=false
-->
<skipTests>${skip.tests}</skipTests>
<includes>
<include>**/*Test.java</include>
<include>**/*IT.java</include>
</includes>
</configuration>
</plugin>
</plugins>
</build>
<repositories>
<repository>
<id>nexus-public</id>
<name>bsch/nexus public</name>
<url>http://nexus.yunhaixingtong.com/repository/maven-public/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>true</enabled></snapshots>
</repository>
</repositories>
</project>
+45
View File
@@ -0,0 +1,45 @@
-- ============================================================
-- bsch_cms 全量清库脚本(清测试数据,全流程重走)
-- 目标库:jdbc:mysql://localhost:3306/bsch_cms(root)
-- 范围:cms_* 八表全空(站点/栏目/文章/轮播/友链/附件/事项/事项字典),AUTO_INCREMENT 归位
-- 不动的东西:
-- * 平台侧账号(cmssuper/cmsschool 等在 userbase 平台库,不在本库)
-- * 主题包(${user.dir}/data/themes,含 .meta 覆盖层;要重置主题请手工清该目录)
-- * 上传的物理文件(D:/bsch/cms/upload/,见文件尾可选清理说明)
-- 表结构由 Hibernate ddl-auto=update 维护,本脚本只清数据不删表。
-- 重灌演示数据:执行同目录 cms_seed_local.sql(其前置即"各表为空",本脚本正好满足)。
-- ============================================================
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
TRUNCATE TABLE cms_attachment;
TRUNCATE TABLE cms_banner;
TRUNCATE TABLE cms_link;
TRUNCATE TABLE cms_article;
TRUNCATE TABLE cms_category;
TRUNCATE TABLE cms_service_item;
TRUNCATE TABLE cms_service_dict;
TRUNCATE TABLE cms_site;
SET FOREIGN_KEY_CHECKS = 1;
-- 验证:八表应全为 0
SELECT 'cms_site' AS tbl, COUNT(*) AS remain FROM cms_site
UNION ALL SELECT 'cms_category', COUNT(*) FROM cms_category
UNION ALL SELECT 'cms_article', COUNT(*) FROM cms_article
UNION ALL SELECT 'cms_banner', COUNT(*) FROM cms_banner
UNION ALL SELECT 'cms_link', COUNT(*) FROM cms_link
UNION ALL SELECT 'cms_attachment', COUNT(*) FROM cms_attachment
UNION ALL SELECT 'cms_service_item', COUNT(*) FROM cms_service_item
UNION ALL SELECT 'cms_service_dict', COUNT(*) FROM cms_service_dict;
-- ------------------------------------------------------------
-- 可选(不在 SQL 内):清掉测试期上传的物理文件,只留种子静态资产。
-- 保留:D:/bsch/cms/upload/seed/ (cms_seed_local.sql 的配图依赖它)
-- 删除:seed/ 之外的目录与散文件(测试期真实上传的图片/PDF 均为孤儿)
-- Git Bash 下参考:
-- find /d/bsch/cms/upload -mindepth 1 -maxdepth 1 ! -name seed -exec rm -rf {} +
-- 主题包如需一并重置(连派生皮肤一起清掉,回到内置包):
-- rm -rf <服务运行目录>/data/themes (重启服务后仅剩 classpath 内置包)
-- ------------------------------------------------------------
+122
View File
@@ -0,0 +1,122 @@
-- ============================================================
-- bsch_cms 本地种子数据(纯 SQL 版,2026-09-07 重写)
-- 结构对齐 local-debug/build/day3-seed.cjs(同一份演示数据的 SQL 等价物):
-- 1 站点 demo / 10 栏目(列表 7+单页 2+外链 1)/ 18 文章(13 发布含 3 置顶、3 草稿、2 下架)
-- / 3 轮播(跳文章/栏目/外链各一)/ 6 友链(3 组)/ 8 附件(6 图 + 2 文档)
-- 与 cjs 版的差异(有意):cjs 正文图走真实上传接口(PNG,uuid 文件名);
-- 本 SQL 引用静态种子 SVG(front/mock/seed-assets.cjs 生成于 D:/bsch/cms/upload/seed/),
-- art-1/art-2 在媒体/教学栏目复用(静态资产仅 6 张场景图)。
-- 约定(B-1 / D3-8 / D6-1):
-- * 上传地址一律相对路径 /cms/upload/... 入库,渲染期按访问域名补基准(禁绝对 URL)
-- * 不配 public_base_url / domain(未配置=按请求派生,不写死 localhost)
-- * 已废弃键 footer(死键)不出现;页脚= contact/copyright/beian
-- 前置:cms_* 六表为空(Hibernate 自建表结构);种子账号 cmssuper/cmsschool(sch_id=990001)
-- 清理:DELETE FROM cms_attachment; DELETE FROM cms_banner; DELETE FROM cms_link;
-- DELETE FROM cms_article; DELETE FROM cms_category; DELETE FROM cms_site;
-- 验证:2026-09-07 本地 MySQL 8.0.45 scratch 库(结构同 bsch_cms)整文件执行通过
-- ============================================================
-- 1. 站点(config_json 五键与 day3-seed.cjs 一致;logo 相对路径)
INSERT INTO cms_site (id, code, path, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(1, 'demo', 'demo', 0, 990001, '云海大学', '云海大学门户网站', 'default',
'{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "地址:云海市学府路 1 号 · 电话:010-88888888", "copyright": "云海大学 版权所有 © 2026(演示数据)", "beian": "京ICP备00000000号-1 · 京公网安备110000000000号", "seo_description": "云海大学门户网站演示站点(day3 验收演示数据)"}',
1, 'cmssuper', NOW());
-- 2. 栏目(三种类型各就位:列表 7(含 2 父)/ 单页 2 / 外链 1;排序与 cjs 相同)
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, seo_title, status, create_by, create_time) VALUES
(1, 1, 0, '学校概况', 2, NULL, '<h2>学校简介</h2><p>云海大学创建于 1958 年,是一所以工为主、多学科协调发展的综合性大学。(演示数据)</p>', 1, NULL, 1, 'cmssuper', NOW()),
(2, 1, 0, '新闻中心', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()),
(3, 1, 2, '通知公告', 1, NULL, NULL, 1, NULL, 1, 'cmssuper', NOW()),
(4, 1, 2, '校园动态', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()),
(5, 1, 2, '媒体报道', 1, NULL, NULL, 3, NULL, 1, 'cmssuper', NOW()),
(6, 1, 0, '教学科研', 1, NULL, NULL, 3, NULL, 1, 'cmssuper', NOW()),
(7, 1, 6, '教学动态', 1, NULL, NULL, 1, NULL, 1, 'cmssuper', NOW()),
(8, 1, 6, '科研成果', 1, NULL, NULL, 2, NULL, 1, 'cmssuper', NOW()),
(9, 1, 0, '招生网', 3, 'https://zs.example.edu.cn', NULL, 4, NULL, 1, 'cmssuper', NOW()),
(10, 1, 0, '联系我们', 2, NULL, '<p>地址:云海市学府路 1 号</p><p>邮编:100000</p><p>电话:010-88888888</p>', 5, NULL, 1, 'cmssuper', NOW());
-- 3. 文章(5 个叶子列表栏目 × 3~4 篇;状态组合与 day3-seed.cjs 逐篇一致:
-- 发布 13(含置顶 3)/ 草稿 3 / 下架 2;发布后下架者保留 publish_time)
-- 图文篇配图/art-N.svg、封面/cover-*.svg 均相对路径(B-1)
INSERT INTO cms_article (id, site_id, category_id, title, summary, content, cover_image, author_account, author_name, source, status, topmost, publish_time, view_count, create_by, create_time) VALUES
(1, 1, 3, '关于 2026 年秋季学期开学注册安排的通知', '现将 2026 年秋季学期开学注册有关事项通知如下。',
'<p>关于 2026 年秋季学期开学注册安排的通知——演示数据正文。本篇配图为种子静态资源。</p><p>各单位、全体同学:2026 年秋季学期开学注册定于 9 月 6 日至 7 日进行,请按各学院通知分批办理。</p><p><img src="/cms/upload/seed/art-1.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
NULL, 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 1 DAY, 1411, 'cmssuper', NOW() - INTERVAL 1 DAY),
(2, 1, 3, '图书馆国庆节期间开放时间调整公告', NULL,
'<p>图书馆开放时间调整——演示数据正文。本篇配图为种子静态资源。</p><p>国庆节期间(10 月 1 日至 7 日)图书馆开放时间调整为 9:00-16:00,10 月 8 日起恢复正常。</p><p><img src="/cms/upload/seed/art-2.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 3 DAY, 499, 'cmssuper', NOW() - INTERVAL 2 DAY),
(3, 1, 3, '校园网络升级维护通知(草稿)', NULL,
'<p>校园网络升级维护通知(草稿)——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 680, 'cmssuper', NOW() - INTERVAL 1 DAY),
(4, 1, 3, '关于开展 2026 级新生入学教育系列活动的通知', NULL,
'<p>关于开展 2026 级新生入学教育系列活动的通知——演示数据正文。本篇配图为种子静态资源。</p><p><img src="/cms/upload/seed/art-3.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 6 DAY, 1899, 'cmssuper', NOW() - INTERVAL 2 DAY),
(5, 1, 4, '我校成功举办第 38 届田径运动会', '为期三天的校运会圆满落幕,16 个学院代表队参赛。',
'<p>我校成功举办第 38 届田径运动会——演示数据正文。本篇配图为种子静态资源。</p><p>为期三天的校运会圆满落幕,16 个学院代表队、1200 余名运动员参赛,打破校纪录 3 项。</p><p><img src="/cms/upload/seed/art-4.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
'/cms/upload/seed/cover-运动会.svg', 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 2 DAY, 134, 'cmssuper', NOW() - INTERVAL 5 DAY),
(6, 1, 4, '2026 年迎新晚会精彩回顾', NULL,
'<p>2026 年迎新晚会精彩回顾——演示数据正文。本篇配图为种子静态资源。</p><p><img src="/cms/upload/seed/art-5.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
'/cms/upload/seed/cover-迎新晚会.svg', 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 5 DAY, 1497, 'cmssuper', NOW() - INTERVAL 5 DAY),
(7, 1, 4, '大学生创新创业大赛校内选拔赛落幕', NULL,
'<p>大学生创新创业大赛校内选拔赛落幕——演示数据正文。本篇配图为种子静态资源。</p><p><img src="/cms/upload/seed/art-6.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
'/cms/upload/seed/cover-创新创业.svg', 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 9 DAY, 299, 'cmssuper', NOW() - INTERVAL 7 DAY),
(8, 1, 4, '2025 年优秀学生表彰大会报道(已下架)', NULL,
'<p>2025 年优秀学生表彰大会报道(已下架)——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 2, 0, NOW() - INTERVAL 40 DAY, 1123, 'cmssuper', NOW() - INTERVAL 40 DAY),
(9, 1, 5, '《云海日报》:云海大学深耕产教融合一线观察', NULL,
'<p>《云海日报》:云海大学深耕产教融合一线观察——演示数据正文。本篇配图为种子静态资源(art-1 复用)。</p><p><img src="/cms/upload/seed/art-1.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
NULL, 'cmssuper', '张老师', '云海日报', 1, 0, NOW() - INTERVAL 4 DAY, 566, 'cmssuper', NOW() - INTERVAL 6 DAY),
(10, 1, 5, '市电视台专访我校智能制造实验室', NULL,
'<p>市电视台专访我校智能制造实验室——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '云海电视台', 1, 0, NOW() - INTERVAL 12 DAY, 331, 'cmssuper', NOW() - INTERVAL 9 DAY),
(11, 1, 5, '志愿服务月活动启动(草稿)', NULL,
'<p>志愿服务月活动启动(草稿)——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 108, 'cmssuper', NOW() - INTERVAL 8 DAY),
(12, 1, 7, '我校 12 门课程获评省级一流本科课程', NULL,
'<p>我校 12 门课程获评省级一流本科课程——演示数据正文。本篇配图为种子静态资源(art-2 复用)。</p><p><img src="/cms/upload/seed/art-2.svg" alt="演示配图" style="max-width:100%"/></p><p>第二段正文,验证排版与图片渲染。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 7 DAY, 208, 'cmssuper', NOW() - INTERVAL 11 DAY),
(13, 1, 7, '关于组织申报 2027 年教改项目的预通知', NULL,
'<p>关于组织申报 2027 年教改项目的预通知——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 15 DAY, 97, 'cmssuper', NOW() - INTERVAL 13 DAY),
(14, 1, 7, '人工智能赋能课堂教学改革研讨会举行', NULL,
'<p>人工智能赋能课堂教学改革研讨会举行——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 10 DAY, 156, 'cmssuper', NOW() - INTERVAL 10 DAY),
(15, 1, 8, '材料学院团队在《Nature》子刊发表最新成果', '该研究首次揭示了新型钙钛矿材料的界面稳定性机制。',
'<p>材料学院团队在《Nature》子刊发表最新成果——演示数据正文。</p><p>该研究首次揭示了新型钙钛矿材料的界面稳定性机制,为下一代光伏器件提供理论基础。</p>',
NULL, 'cmssuper', '张老师', '', 1, 1, NOW() - INTERVAL 2 DAY, 1730, 'cmssuper', NOW() - INTERVAL 14 DAY),
(16, 1, 8, '我校获国家重点研发计划项目立项 3 项', NULL,
'<p>我校获国家重点研发计划项目立项 3 项——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 1, 0, NOW() - INTERVAL 18 DAY, 145, 'cmssuper', NOW() - INTERVAL 16 DAY),
(17, 1, 8, '大模型教育应用白皮书编制启动(草稿)', NULL,
'<p>大模型教育应用白皮书编制启动(草稿)——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 0, 0, NULL, 12, 'cmssuper', NOW() - INTERVAL 3 DAY),
(18, 1, 8, '校企合作联合实验室签约(已下架)', NULL,
'<p>校企合作联合实验室签约(已下架)——演示数据正文。</p>',
NULL, 'cmssuper', '张老师', '', 2, 0, NOW() - INTERVAL 30 DAY, 87, 'cmssuper', NOW() - INTERVAL 30 DAY);
-- 4. 轮播(3 张:跳栏目 / 跳文章 / 跳外链;第 2 张目标=迎新晚会(cjs 版 publishedIds[4] 的等价行))
INSERT INTO cms_banner (id, site_id, position, title, image_url, link_type, link_value, sort_no, status, create_by, create_time) VALUES
(1, 1, 'home', '笃学慎思 明辨笃行', '/cms/upload/seed/banner-1.svg', 2, '2', 1, 1, 'cmssuper', NOW()),
(2, 1, 'home', '第 38 届田径运动会', '/cms/upload/seed/banner-2.svg', 1, '6', 2, 1, 'cmssuper', NOW()),
(3, 1, 'home', '2026 年招生咨询', '/cms/upload/seed/banner-3.svg', 3, 'https://zs.example.edu.cn', 3, 1, 'cmssuper', NOW());
-- 5. 友情链接(3 组 × 2 条;logo 相对路径)
INSERT INTO cms_link (id, site_id, group_name, name, logo_url, link_url, sort_no, status, create_by, create_time) VALUES
(1, 1, '上级部门', '教育部', '/cms/upload/seed/logo-1.svg', 'http://www.moe.gov.cn', 1, 1, 'cmssuper', NOW()),
(2, 1, '上级部门', '省教育厅', '/cms/upload/seed/logo-2.svg', 'http://jyt.example.cn', 2, 1, 'cmssuper', NOW()),
(3, 1, '兄弟院校', '云海理工大学', '/cms/upload/seed/logo-3.svg', 'https://www.yhlg.edu.cn', 3, 1, 'cmssuper', NOW()),
(4, 1, '兄弟院校', '云海师范大学', '/cms/upload/seed/logo-4.svg', 'https://www.yhsf.edu.cn', 4, 1, 'cmssuper', NOW()),
(5, 1, '常用服务', '教务系统', '/cms/upload/seed/logo-6.svg', 'https://jw.yunhai-demo.edu.cn', 5, 1, 'cmssuper', NOW()),
(6, 1, '常用服务', '图书馆', '/cms/upload/seed/logo-7.svg', 'https://lib.yunhai-demo.edu.cn', 6, 1, 'cmssuper', NOW());
-- 6. 附件(对应 cjs 版 8 张真实上传的等价行:6 张种子图(不绑文章=孤儿,演示孤儿清理)
-- + 2 个 PDF(绑定文章 1/5,演示文档附件);web_url 一律相对路径(B-1))
INSERT INTO cms_attachment (id, site_id, article_id, file_name, file_path, web_url, file_size, mime_type, create_by, create_time) VALUES
(1, 1, NULL, 'scene-1.svg', 'seed/art-1.svg', '/cms/upload/seed/art-1.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 1 DAY),
(2, 1, NULL, 'scene-2.svg', 'seed/art-2.svg', '/cms/upload/seed/art-2.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 2 DAY),
(3, 1, NULL, 'scene-3.svg', 'seed/art-3.svg', '/cms/upload/seed/art-3.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 6 DAY),
(4, 1, NULL, 'scene-4.svg', 'seed/art-4.svg', '/cms/upload/seed/art-4.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 2 DAY),
(5, 1, NULL, 'scene-5.svg', 'seed/art-5.svg', '/cms/upload/seed/art-5.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 5 DAY),
(6, 1, NULL, 'scene-6.svg', 'seed/art-6.svg', '/cms/upload/seed/art-6.svg', 2100, 'image/svg+xml', 'cmssuper', NOW() - INTERVAL 9 DAY),
(7, 1, 1, '2026秋季学期校历.pdf', 'seed/2026校历.pdf', '/cms/upload/seed/2026校历.pdf', 620, 'application/pdf', 'cmssuper', NOW() - INTERVAL 1 DAY),
(8, 1, 5, '运动会秩序册.pdf', 'seed/运动会秩序册.pdf', '/cms/upload/seed/运动会秩序册.pdf', 630, 'application/pdf', 'cmssuper', NOW() - INTERVAL 5 DAY);
+62
View File
@@ -0,0 +1,62 @@
-- ============================================================
-- 视觉精修边界状态种子(2026-09-06,docs/decisions.md 决策 D0-2)
-- 目的:edge 站覆盖 空列表/无封面/超长标题/单轮播/无友链/富文本详情 等边界;
-- demo 站补全页脚三键 + 院系子站(header 专业院系下拉);下架手工测试轮播"213"。
-- 清理(恢复原状):
-- UPDATE cms_banner SET status=1 WHERE id=5;
-- UPDATE cms_site SET config_json=JSON_REMOVE(config_json,'$.contact','$.copyright','$.beian') WHERE code='demo';
-- DELETE FROM cms_site WHERE code IN ('edge','jdx','xxgc');
-- DELETE FROM cms_category WHERE site_id IN (SELECT id FROM cms_site WHERE code='edge') OR id IN (61,62,63);
-- DELETE FROM cms_article WHERE site_id IN (SELECT id FROM cms_site WHERE code='edge');
-- DELETE FROM cms_banner WHERE title='边界站唯一轮播';
-- (注:site 删除需先删子表行,上面顺序已按依赖排列;jdx/xxgc 的栏目/文章见 61,62,63)
-- ============================================================
-- 1) demo:页脚三键补全(模板读 contact/copyright/beian)+ 下架测试轮播
UPDATE cms_site SET config_json = JSON_SET(config_json,
'$.contact', '地址:云海市学府路 1 号 · 电话:010-88888888',
'$.copyright', '云海大学 版权所有',
'$.beian', '京ICP备00000000号-1 · 京公网安备110000000000号')
WHERE code = 'demo';
UPDATE cms_banner SET status = 0 WHERE id = 5;
-- 2) 院系子站(demo 的子站:header 出"专业院系"下拉;不配 public_base_url=按请求派生)
-- 主题应用:jdx→academic-navy(学府藏蓝)、xxgc→campus-azure(青蓝校园)——两套高校官网风皮肤随种子落地可视验收
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(6, 'jdx', 1, 990001, '云海大学', '机电学院', 'academic-navy', '{"copyright": "机电学院"}', 1, 'cmssuper', NOW()),
(7, 'xxgc', 1, 990001, '云海大学', '信息工程学院', 'campus-azure', '{"copyright": "信息工程学院"}', 1, 'cmssuper', NOW());
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES
(61, 6, 0, '学院概况', 2, NULL, '<p>机电学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW()),
(62, 6, 0, '学院新闻', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
(63, 7, 0, '学院概况', 2, NULL, '<p>信息工程学院(视觉精修种子)。</p>', 1, 1, 'cmssuper', NOW());
-- 3) edge 边界站:单轮播 / 空列表栏目 / 图文栏目(无封面+超长标题+富文本) / 单页(富+空)
INSERT INTO cms_site (id, code, parent_site_id, sch_id, sch_name, name, theme, config_json, status, create_by, create_time) VALUES
(5, 'edge', 0, 990001, '云海大学', '边界状态演示站', 'default',
'{"logo": "/cms/upload/seed/logo-univ.svg", "contact": "边界站 · 视觉回归用", "copyright": "边界状态演示站", "beian": "无备案(演示数据)", "seo_description": "门户视觉边界状态演示站"}',
1, 'cmssuper', NOW());
INSERT INTO cms_category (id, site_id, parent_id, name, type, link_url, content, sort_no, status, create_by, create_time) VALUES
(51, 5, 0, '通知公告', 1, NULL, NULL, 1, 1, 'cmssuper', NOW()),
(52, 5, 0, '图文快讯', 1, NULL, NULL, 2, 1, 'cmssuper', NOW()),
(53, 5, 0, '学校简介', 2, NULL,
'<h2>学校概况</h2><p>边界状态演示站用于验证门户在极端内容形态下的视觉稳定性。本页包含标题、段落、图片、表格、列表、引用等全部正文元素。</p><figure><img src="/cms/upload/seed/art-2.svg" alt="校园示意图"><figcaption>校园示意图(640×360 演示图)</figcaption></figure><h3>基本数据</h3><table><thead><tr><th>指标</th><th>数值</th><th>备注</th></tr></thead><tbody><tr><td>占地面积</td><td>3000 亩</td><td>含新校区</td></tr><tr><td>全日制在校生</td><td>32000 人</td><td>本科生 26000</td></tr><tr><td>专任教师</td><td>1800 人</td><td>含院士 12 名</td></tr></tbody></table><ul><li>国家级一流本科专业建设点 42 个</li><li>省部级科研平台 28 个</li><li>国际合作院校 120 余所</li></ul><blockquote>笃学慎思,明辨笃行——演示校训。</blockquote><p>以上内容均为本地演示数据,用于视觉回归测试。</p>',
3, 1, 'cmssuper', NOW()),
(54, 5, 0, '空白单页', 2, NULL, '', 4, 1, 'cmssuper', NOW());
INSERT INTO cms_banner (site_id, position, title, image_url, link_type, link_value, status, sort_no, create_by, create_time) VALUES
(5, 'home', '边界站唯一轮播', '/cms/upload/seed/banner-2.svg', 1, NULL, 1, 1, 'cmssuper', NOW());
-- 超长标题(约 74 字)
INSERT INTO cms_article (site_id, category_id, title, summary, content, cover_image, author_account, author_name, source, status, topmost, publish_time, view_count, create_by, create_time) VALUES
(5, 52, '关于举办2026年云海大学"数智赋能高等教育高质量发展"系列学术论坛暨第三届校级教育教学成果奖申报动员培训会的通知公告', '超长标题边界样例——验证单行省略、列表行高与详情页标题换行的稳定性。',
'<p>超长标题边界样例正文。本篇用于验证:首页区块/列表页的单行省略、轮播与区块布局稳定性、详情页 H1 长标题换行与行高。</p><p><img src="/cms/upload/seed/art-3.svg" alt="配图"></p>',
'/cms/upload/seed/cover-运动会.svg', 'cmssuper', '边界测试', '', 1, 1, NOW() - INTERVAL 1 DAY, 42, 'cmssuper', NOW() - INTERVAL 1 DAY),
-- 无封面文章
(5, 52, '这篇边界文章没有封面图——验证无封面样式的占位设计', '无封面边界样例:列表不得出现破图或空洞。',
'<p>无封面边界样例正文。列表与首页若展示封面,无封面文章必须呈现精心设计的占位形态而非空白或破图。</p><p><img src="/cms/upload/seed/art-4.svg" alt="配图"></p>',
NULL, 'cmssuper', '边界测试', '', 1, 0, NOW() - INTERVAL 2 DAY, 7, 'cmssuper', NOW() - INTERVAL 2 DAY),
-- 普通文章(有摘要)
(5, 52, '边界站常规文章样例', '常规形态对照样例。',
'<p>常规形态正文,作为边界样式的对照。</p>',
'/cms/upload/seed/cover-创新创业.svg', 'cmssuper', '边界测试', '边界站', 1, 0, NOW() - INTERVAL 3 DAY, 3, 'cmssuper', NOW() - INTERVAL 3 DAY);
-- edge 站不插入任何 cms_link → 友链整段隐藏边界
+35
View File
@@ -0,0 +1,35 @@
-- ============================================================
-- R1 迁移脚本:学校主站编码改 5 位数字码(1 + 学校ID左补零至4位)
-- 例:sch_id=13 → code/path = 10013;幂等(只处理编码≠目标值的行)
-- 前置:W4 嵌套 path 机制已落地(cms_site.path 列已回填,uk_site_path 已建)
-- 旧链接提示:执行后 /portal/sch-13/ 不再解析(301 兼容仅覆盖子站按 code
-- 回查,主站不在其列);已对外发链需在 Caddy 层加旧前缀 301 或接受断链。
-- ============================================================
-- 0) 前置校验:先人工看一眼存量主站
-- ① 同一 sch_id 挂多个顶层站 → 改码后触发 uk_site_path 冲突,需先合并/停用;
-- ② sch_id 为空的顶层站(如演示站)会被本脚本跳过、保持原编码。
SELECT id, code, sch_id, path, status FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
-- 1) 顶层主站:code 与 path 一起改为目标 5 位码(sch_id 全局唯一 ⇒ 天然满足
-- uk_site_path / uk_site_parent_code;sch_id 为空的顶层站跳过)
UPDATE cms_site
SET code = CONCAT('1', LPAD(sch_id, 4, '0')),
path = CONCAT('1', LPAD(sch_id, 4, '0'))
WHERE (parent_site_id = 0 OR parent_site_id IS NULL)
AND sch_id IS NOT NULL
AND code <> CONCAT('1', LPAD(sch_id, 4, '0'));
-- 2) 子站 path 级联重算(code 不动,仅换父前缀;幂等)
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
SET s.path = CONCAT(p.path, '/', s.code)
WHERE s.parent_site_id > 0
AND s.path <> CONCAT(p.path, '/', s.code);
-- 3) 校验(两条都应返回 0 行)
SELECT id, code, path FROM cms_site WHERE path = '' OR path IS NULL;
SELECT id, code, path FROM cms_site WHERE parent_site_id = 0 OR parent_site_id IS NULL;
-- 4) 收尾:缓存为进程内 Caffeine(站点快照 TTL 30min、旧 key 自然过期);
-- 想立即生效可在站点管理保存一次任一站点(触发 SiteContentChangedEvent 清缓存),
-- 或重启 cms 容器。
+138
View File
@@ -0,0 +1,138 @@
-- ============================================================
-- R2 升级脚本:事项库两表(事项库与办事大厅详细设计 §2.5,幂等)
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
-- 顺序:加表(CREATE IF NOT EXISTS 幂等)→ 索引守卫补建(幂等)→ 校验
-- 说明:MySQL 无 ADD INDEX IF NOT EXISTS,建表内联索引;存量库若只建了表缺索引
-- (含 r2 首版之后新增的 idx_svc_item_portal),由第 3 段守卫块自动补建,可反复重跑。
-- ============================================================
-- 1) 事项主表
CREATE TABLE IF NOT EXISTS `cms_service_item` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`item_code` VARCHAR(64) NOT NULL COMMENT '事项编码(站内唯一,跨服务键的一半)',
`title` VARCHAR(200) NOT NULL COMMENT '事项名称',
`summary` VARCHAR(500) NULL COMMENT '一句话简介(列表副标题/快照摘要)',
`object_key` VARCHAR(64) NULL COMMENT '服务对象(→ cms_service_dict.dict_key, dict_type=object)',
`category_key` VARCHAR(64) NULL COMMENT '事项类别(→ cms_service_dict.dict_key, dict_type=category)',
`dept_group_id` INT NULL COMMENT '服务部门(UserBase 组树节点 id 快照)',
`dept_name` VARCHAR(128) NULL COMMENT '部门名快照(改名不自动同步)',
`handle_mode` TINYINT NOT NULL DEFAULT 1 COMMENT '办理方式:1=线上 2=线下 3=线上线下',
`online_flag` TINYINT NOT NULL DEFAULT 1 COMMENT '是否可在线办理:1=是 0=否("在线办理"筛选维度)',
`handle_place` VARCHAR(200) NULL COMMENT '办理地点',
`handle_time_limit` VARCHAR(64) NULL COMMENT '办理时限(文本,如"3 个工作日")',
`promise_time_limit` VARCHAR(64) NULL COMMENT '承诺时限(文本)',
`contact_phone` VARCHAR(64) NULL COMMENT '咨询电话',
`supervise_phone` VARCHAR(64) NULL COMMENT '监督投诉电话',
`process_html` LONGTEXT NULL COMMENT '办理流程 HTML(保存时 jsoup 清洗)',
`material_html` LONGTEXT NULL COMMENT '所需材料 HTML(同上)',
`notice_html` LONGTEXT NULL COMMENT '注意事项 HTML(同上)',
`faq_category_id` INT NULL COMMENT '关联栏目:常见问题(逻辑外键 → cms_category.id)',
`notice_category_id` INT NULL COMMENT '关联栏目:公告信息(同上)',
`rule_category_id` INT NULL COMMENT '关联栏目:文件规定(同上)',
`cover_image` VARCHAR(500) NULL COMMENT '事项图标/封面(上传相对路径)',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '管理端自定义排序(升序,列表第一优先级)',
`status` TINYINT NOT NULL DEFAULT 0 COMMENT '状态:0=草稿 1=已发布 2=已下架',
`publish_time` DATETIME NULL COMMENT '发布时间',
`offline_time` DATETIME NULL COMMENT '下架时间',
`view_count` INT NOT NULL DEFAULT 0 COMMENT '指南页浏览量(异步累加,不承诺精确)',
`ext_json` JSON NULL COMMENT '扩展(一期仅 apply_url:"我要办理"外链)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_item_code` (`site_id`, `item_code`),
KEY `idx_svc_item_list` (`site_id`, `status`, `online_flag`, `sort_no`),
KEY `idx_svc_item_obj` (`site_id`, `status`, `object_key`),
KEY `idx_svc_item_cat` (`site_id`, `status`, `category_key`),
KEY `idx_svc_item_dept` (`site_id`, `status`, `dept_group_id`),
KEY `idx_svc_item_portal` (`site_id`, `status`, `sort_no` ASC, `publish_time` DESC, `id` DESC)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项(L1 事项库主表)';
-- 2) 事项字典表
CREATE TABLE IF NOT EXISTS `cms_service_dict` (
`id` INT NOT NULL AUTO_INCREMENT COMMENT '主键',
`site_id` INT NOT NULL COMMENT '所属站点',
`dict_type` VARCHAR(32) NOT NULL COMMENT '类型:object=服务对象 category=事项类别',
`dict_key` VARCHAR(64) NOT NULL COMMENT '稳定键(站内同类型唯一;被事项引用后不可改)',
`dict_name` VARCHAR(128) NOT NULL COMMENT '展示名',
`sort_no` INT NOT NULL DEFAULT 0 COMMENT '排序(升序)',
`status` TINYINT NOT NULL DEFAULT 1 COMMENT '状态:0=停用 1=启用(停用不出现在筛选,存量引用仍可渲染)',
`ext_json` JSON NULL COMMENT '预留扩展(颜色/图标等)',
`create_by` VARCHAR(64) NULL,
`create_time` DATETIME NULL,
`update_by` VARCHAR(64) NULL,
`update_time` DATETIME NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_svc_dict` (`site_id`, `dict_type`, `dict_key`),
KEY `idx_svc_dict_list` (`site_id`, `dict_type`, `status`, `sort_no`)
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COMMENT = 'CMS 事项字典(服务对象/事项类别)';
-- 3) 索引守卫补建(仅当表已存在但缺索引时生效;幂等,可反复重跑)
-- idx_svc_item_portal:门户服务中心列表 ORDER BY sort_no ASC, publish_time DESC, id DESC 的对症索引
-- (原 idx_svc_item_list 前导列含门户未过滤的 online_flag,无法用于该排序 → 必然 filesort)
SET @db := DATABASE();
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'uk_svc_item_code') = 0,
'ALTER TABLE cms_service_item ADD UNIQUE KEY uk_svc_item_code (site_id, item_code)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_list') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_list (site_id, status, online_flag, sort_no)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_obj') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_obj (site_id, status, object_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_cat') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_cat (site_id, status, category_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_dept') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_dept (site_id, status, dept_group_id)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_item'
AND index_name = 'idx_svc_item_portal') = 0,
'ALTER TABLE cms_service_item ADD KEY idx_svc_item_portal (site_id, status, sort_no ASC, publish_time DESC, id DESC)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_dict'
AND index_name = 'uk_svc_dict') = 0,
'ALTER TABLE cms_service_dict ADD UNIQUE KEY uk_svc_dict (site_id, dict_type, dict_key)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
SET @sql := IF((SELECT COUNT(*) FROM information_schema.statistics
WHERE table_schema = @db AND table_name = 'cms_service_dict'
AND index_name = 'idx_svc_dict_list') = 0,
'ALTER TABLE cms_service_dict ADD KEY idx_svc_dict_list (site_id, dict_type, status, sort_no)', 'DO 0');
PREPARE stmt FROM @sql; EXECUTE stmt; DEALLOCATE PREPARE stmt;
-- 4) 校验
-- 4.1) 应返回 0 行(两表存在且注释非空)
SELECT TABLE_NAME FROM information_schema.TABLES
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME IN ('cms_service_item', 'cms_service_dict')
AND TABLE_COMMENT = '';
-- 4.2) 应返回 10 行(两表全部索引就位:主键 2 + 唯一键 2 + 普通索引 6)
SELECT table_name, index_name, GROUP_CONCAT(column_name ORDER BY seq_in_index) AS cols
FROM information_schema.statistics
WHERE table_schema = DATABASE()
AND table_name IN ('cms_service_item', 'cms_service_dict')
GROUP BY table_name, index_name
ORDER BY table_name, index_name;
+21
View File
@@ -0,0 +1,21 @@
-- ============================================================
-- 皮肤升级脚本:粉/紫两包下线 → 高校官网风两包接管(幂等,存量库执行一次即可)
-- 背景:cherry-bloom(樱语粉韵)/ violet-iris(鸢尾紫韵)不符合高校门户视觉,已从模板库删除;
-- 存量站点若仍指向这两个主题,ThemeRegistry 匹配不到会静默回落 default(皮肤全丢),
-- 故必须把 theme 列改到新包:academic-navy(学府藏蓝)/ campus-azure(青蓝校园)。
-- 目标库:存量 bsch_cms(生产/预发);干净环境无需本脚本
-- 顺序:备份查询 → 回填 → 校验
-- ============================================================
-- 1) 先看受影响站点(人工确认后再执行第 2 步)
SELECT id, path, name, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 2) 回填:粉韵→学府藏蓝,紫韵→青蓝校园(仅命中旧值,重复执行无副作用)
UPDATE cms_site SET theme = 'academic-navy' WHERE theme = 'cherry-bloom';
UPDATE cms_site SET theme = 'campus-azure' WHERE theme = 'violet-iris';
-- 3) 校验(应返回 0 行)
SELECT id, path, theme FROM cms_site WHERE theme IN ('cherry-bloom', 'violet-iris');
-- 备注:站点快照走 Caffeine CacheNames.SITE,TTL 30 分钟(±60s 抖动,见 CacheConfig),
-- 实测改库后 400s 门户仍是旧皮肤——改库后请重启服务,或在管理端重新保存一次站点(换肤会主动清缓存)。
+31
View File
@@ -0,0 +1,31 @@
-- ============================================================
-- W4 升级脚本:cms_site 嵌套 path(《功能修订方案.md》§8 / 附录 A,幂等)
-- 目标库:存量 bsch_cms(生产/预发);干净环境直接用 cms_init.sql 无需本脚本
-- 顺序:加列 → 回填(先顶层后子站,两层固定一轮)→ 约束切换 → 校验
-- ============================================================
-- 1) 加列(存量默认空串,随后回填;已加列则跳过)
ALTER TABLE cms_site ADD COLUMN path VARCHAR(191) NOT NULL DEFAULT '' COMMENT '门户路径(顶层=code;子站=父path/code),URL 唯一标识';
-- 2) 回填:先顶层后子站(幂等:仅处理 path='')
UPDATE cms_site SET path = code WHERE (parent_site_id = 0 OR parent_site_id IS NULL) AND path = '';
UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
SET s.path = CONCAT(p.path, '/', s.code)
WHERE s.parent_site_id > 0 AND s.path = '';
-- 3) 约束切换(存量数据天然满足:code 曾全局唯一 ⇒ path 唯一)
ALTER TABLE cms_site DROP INDEX uk_site_code;
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_path (path);
ALTER TABLE cms_site ADD UNIQUE KEY uk_site_parent_code (parent_site_id, code);
-- 4) 校验(应返回 0 行)
SELECT id, code, path FROM cms_site WHERE path = '';
-- ============================================================
-- 附:R1 预留模板——学校主站编码改学校 ID(1000xxx)级联重算子站 path
-- (切换生成规则后执行;把 {OLD_PREFIX} 替换为旧前缀、按新编码规则回填顶层)
-- UPDATE cms_site SET path = CONCAT('1000', sch_id) WHERE parent_site_id = 0 AND code LIKE '{OLD_PREFIX}%';
-- UPDATE cms_site s JOIN cms_site p ON s.parent_site_id = p.id
-- SET s.path = CONCAT(p.path, '/', s.code)
-- WHERE s.parent_site_id > 0;
-- ============================================================
@@ -0,0 +1,26 @@
package com.yunhai.cms;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 云海平台高校门户 CMS。
*
* <p>一个服务两副面孔(方案 §3.1):
* <ul>
* <li>{@code /cms/admin} —— Vue 管理端 SPA(静态资源,编辑人员经 CAS SSO 登录,数据走 {@code /api/*})</li>
* <li>{@code /cms/portal/{siteCode}/**} —— Thymeleaf SSR 门户(匿名可访问,SEO 刚需)</li>
* </ul>
* CAS 只拦 {@code /api/*}(yh.sso.url-pattern),门户必须匿名,勿改配置(方案 §7.1)。
*/
@EnableCaching
@EnableScheduling
@SpringBootApplication
public class CmsApplication {
public static void main(String[] args) {
SpringApplication.run(CmsApplication.class, args);
}
}
@@ -0,0 +1,41 @@
package com.yunhai.cms.common;
import com.yunhai.cms.entity.BaseEntity;
import com.yunhai.platform.auth.LoginUser;
import com.yunhai.platform.auth.UserContextHolder;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.persistence.PrePersist;
import javax.persistence.PreUpdate;
import java.time.LocalDateTime;
/**
* 审计字段自动填充(Notice NoticeAuditListener 同款模式)。
* 账号取 ThreadLocal 中的 CAS 会话用户;门户匿名渲染、定时任务等无会话场景为 null。
*/
@Slf4j
@Component
public class CmsAuditListener {
@PrePersist
public void prePersist(BaseEntity entity) {
String account = currentAccount();
LocalDateTime now = LocalDateTime.now();
entity.setCreateBy(account);
entity.setCreateTime(now);
entity.setUpdateBy(account);
entity.setUpdateTime(now);
}
@PreUpdate
public void preUpdate(BaseEntity entity) {
entity.setUpdateBy(currentAccount());
entity.setUpdateTime(LocalDateTime.now());
}
private String currentAccount() {
LoginUser user = UserContextHolder.get();
return user == null ? null : user.getLoginName();
}
}
@@ -0,0 +1,116 @@
package com.yunhai.cms.config;
import com.github.benmanes.caffeine.cache.Caffeine;
import com.github.benmanes.caffeine.cache.Expiry;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.CachingConfigurer;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.cache.support.SimpleCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.lang.NonNull;
import java.time.Duration;
import java.util.Arrays;
import java.util.concurrent.ThreadLocalRandom;
/**
* Caffeine 本地缓存装配(方案 §8.4 工程约束)。
*
* <ul>
* <li>TTL 随机抖动 ±60s 防雪崩:自定义 {@link JitterExpiry} 按条目随机;</li>
* <li>null 哨兵 30s 防穿透(爬虫扫随机 ID):写入值为 Spring NullValue 标记时短 TTL;</li>
* <li>CacheErrorHandler:缓存异常按 miss 降级 + error 日志(dirty data risk, TTL 自愈)。</li>
* </ul>
* 切 Redis 时仅替换本配置与 spring.cache.type(方案 §1.2/§12);
* yml 中 spring.cache.caffeine.spec 供默认管理器/演进期使用,本管理器按 CacheNames 显式装配。
*/
@Configuration
@EnableCaching
public class CacheConfig implements CachingConfigurer {
/** 抖动幅度:±60s */
private static final Duration JITTER = Duration.ofSeconds(60);
/** null 哨兵 TTL */
private static final Duration NULL_TTL = Duration.ofSeconds(30);
/** 抖动后 TTL 下限,避免抖出负数/过短 */
private static final Duration MIN_TTL = Duration.ofSeconds(30);
private final LoggingCacheErrorHandler errorHandler;
public CacheConfig(LoggingCacheErrorHandler errorHandler) {
this.errorHandler = errorHandler;
}
@Bean
@Override
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
manager.setCaches(Arrays.asList(
new CaffeineCache(CacheNames.SITE, caffeine(Duration.ofMinutes(30)).build()),
new CaffeineCache(CacheNames.ARTICLE, caffeine(Duration.ofMinutes(5)).build()),
new CaffeineCache(CacheNames.LIST, caffeine(Duration.ofMinutes(5)).build()),
new CaffeineCache(CacheNames.SERVICE_ITEM, caffeine(Duration.ofMinutes(5)).build())));
return manager;
}
@Override
public CacheErrorHandler errorHandler() {
return errorHandler;
}
private Caffeine<Object, Object> caffeine(Duration baseTtl) {
return Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfter(new JitterExpiry(baseTtl));
}
/**
* 按 TTL 基准 + 随机抖动的过期策略;null 哨兵固定 30s。
*/
static class JitterExpiry implements Expiry<Object, Object> {
private final Duration baseTtl;
JitterExpiry(Duration baseTtl) {
this.baseTtl = baseTtl;
}
@Override
public long expireAfterCreate(@NonNull Object key, Object value, long currentTime) {
if (isNullValue(value)) {
return NULL_TTL.toNanos();
}
long jitterMillis = ThreadLocalRandom.current()
.nextLong(-JITTER.toMillis(), JITTER.toMillis());
Duration jittered = baseTtl.plusMillis(jitterMillis);
if (jittered.compareTo(MIN_TTL) < 0) {
jittered = MIN_TTL;
}
return jittered.toNanos();
}
@Override
public long expireAfterUpdate(@NonNull Object key, Object value, long currentTime,
long currentDuration) {
return currentDuration;
}
@Override
public long expireAfterRead(@NonNull Object key, Object value, long currentTime,
long currentDuration) {
return currentDuration;
}
/**
* Spring 把 null 包成 NullValue 单例存入 Caffeine(Caffeine 本身不收 null)。
* 用类名判断避免对 Spring 内部类硬编译依赖。
*/
private boolean isNullValue(Object value) {
return value == null
|| "org.springframework.cache.support.NullValue".equals(value.getClass().getName());
}
}
}
@@ -0,0 +1,32 @@
package com.yunhai.cms.config;
/**
* 缓存名常量(方案 §8.4)。
* <p>javadoc 即失效契约:CacheNames 与失效事件的对应关系集中在此,禁止散落魔法串。
* <ul>
* <li>{@link #SITE}:siteCode → 站点+栏目树+轮播+友链快照,TTL 30min±60s;
* 失效事件 = 站点/栏目/轮播/友链 变更(SiteContentChangedEvent)</li>
* <li>{@link #ARTICLE}:articleId → 详情,TTL 5min±60s;articleId 不存在时缓存 null 哨兵 30s 防穿透;
* 失效事件 = 文章发布/修改/下架(ArticleChangedEvent / ArticlePublishedEvent)</li>
* <li>{@link #LIST}:siteId:categoryId:page → 列表页数据,TTL 5min±60s(null 哨兵同上);
* 失效事件 = 该站点文章发布/下架/修改</li>
* <li>{@link #SERVICE_ITEM}:itemId → 事项指南页模型;filters:{siteId} → 三组筛选项+计数,
* TTL 5min±60s(itemId 不存在时 null 哨兵 30s 防穿透);
* 失效事件 = 事项发布/修改/下架/删除、字典变更(ServiceItemChangedEvent,仅事项域,
* 不触碰 cms:article/cms:list/cms:site)</li>
* </ul>
*/
public final class CacheNames {
/** 门户站点快照(首页数据源) */
public static final String SITE = "cms:site";
/** 门户文章详情 */
public static final String ARTICLE = "cms:article";
/** 门户栏目列表页 */
public static final String LIST = "cms:list";
/** 事项指南页模型 + 筛选项(key=itemId / filters:{siteId},事项库 §3.6) */
public static final String SERVICE_ITEM = "cms:svcItem";
private CacheNames() {
}
}
@@ -0,0 +1,30 @@
package com.yunhai.cms.config;
import com.yunhai.platform.utils.model.BoaUser;
import org.springframework.stereotype.Component;
/**
* CMS 权限判定(对齐 UserBase GroupAuthzService#isSuperAdmin 与 UserTypeEnum 平台约定)。
*
* <p>超管规则:{@code BoaUser.type == 1}(UserTypeEnum.SUPER_ADMIN,系统种子账号),
* 或 {@code BoaUser.id == 100000}(基石超级管理员账号,同 UserBase 内部判定)。
* type 随认证链路经 BoaUserLoader 从 UserBase 换出,无需任何注入配置。
* 学校管理员(type=2,持 ADMIN 角色)与学校领导不特判,统一走
* {@code currentRole.schId → site.schId} 校级规则(方案 §7.2 规则②)。</p>
*/
@Component
public class CmsAuthProperties {
/** UserBase 基石超级管理员账号 id(DicUtil.SUPER_ADMIN_ID,平台约定常量) */
private static final int PLATFORM_ROOT_USER_ID = 100000;
public boolean isSuperAdmin(BoaUser user) {
if (user == null) {
return false;
}
if (user.getType() != null && user.getType() == 1) {
return true;
}
return user.getId() != null && user.getId() == PLATFORM_ROOT_USER_ID;
}
}
@@ -0,0 +1,28 @@
package com.yunhai.cms.config;
import org.springframework.boot.autoconfigure.jackson.Jackson2ObjectMapperBuilderCustomizer;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateTimeDeserializer;
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateTimeSerializer;
import java.time.format.DateTimeFormatter;
/**
* LocalDateTime 统一按 yyyy-MM-dd HH:mm:ss 序列化/反序列化
* (yml 的 spring.jackson.date-format 不作用于 JSR310,需显式定制;UserHome JacksonConfig 同意图)。
*/
@Configuration
public class JacksonConfig {
private static final DateTimeFormatter DATE_TIME =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
@Bean
public Jackson2ObjectMapperBuilderCustomizer localDateTimeCustomizer() {
return builder -> builder
.serializers(new LocalDateTimeSerializer(DATE_TIME))
.deserializers(new LocalDateTimeDeserializer(DATE_TIME));
}
}
@@ -0,0 +1,40 @@
package com.yunhai.cms.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.interceptor.CacheErrorHandler;
import org.springframework.lang.NonNull;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;
/**
* 缓存异常降级(方案 §8.4):get 异常按 miss 返回 null,put/evict/clear 异常吞掉;
* 脏数据风险靠 TTL 自愈,只留 error 日志定位。
*/
@Slf4j
@Component
public class LoggingCacheErrorHandler implements CacheErrorHandler {
@Override
public void handleCacheGetError(@NonNull RuntimeException ex, @NonNull Cache cache,
@NonNull Object key) {
log.error("cache get error, treat as miss, cache={}, key={}", cache.getName(), key, ex);
}
@Override
public void handleCachePutError(@NonNull RuntimeException ex, @NonNull Cache cache,
@NonNull Object key, @Nullable Object value) {
log.error("cache dirty data risk, TTL自愈, cache={}, key={}", cache.getName(), key, ex);
}
@Override
public void handleCacheEvictError(@NonNull RuntimeException ex, @NonNull Cache cache,
@NonNull Object key) {
log.error("cache dirty data risk, TTL自愈, cache={}, key={}", cache.getName(), key, ex);
}
@Override
public void handleCacheClearError(@NonNull RuntimeException ex, @NonNull Cache cache) {
log.error("cache dirty data risk, TTL自愈, cache={}", cache.getName(), ex);
}
}
@@ -0,0 +1,84 @@
package com.yunhai.cms.config;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.thymeleaf.IEngineConfiguration;
import org.thymeleaf.templateresolver.FileTemplateResolver;
import org.thymeleaf.templateresolver.ITemplateResolver;
import org.thymeleaf.templateresource.FileTemplateResource;
import org.thymeleaf.templateresource.ITemplateResource;
import java.nio.file.Paths;
/**
* W1(G7 关键,方案 §5.4 步骤 3):为 Thymeleaf 追加外部主题目录的文件解析器——
* 视图名 {@code themes/{外部包}/home} 改写为文件路径 {cms.theme.dir}/{外部包}/home.html;
* 内置包名不改写(返回不存在资源,checkExistence=true 时本解析器跳过,由 classpath 解析器处理)。
*
* <p>Spring 的 orderedStream 只认 {@code @Order}/{@code Ordered}(Thymeleaf 的 getOrder() 不参与
* Spring 排序):必须排最前,否则 Boot 默认 SpringResourceTemplateResolver(checkExistence=false)
* 会抢先 claim 导致外部包模板解析不到。cacheable=true,registry.reload() 时经
* TemplateEngine.clearTemplateCache() 清缓存;目录经 registry.getThemeDir() 动态读取
* (测试可指向临时目录)。外部包门户 SSR 的前提(T-W1-19)。
*
* <p>Thymeleaf 3.0.15 的 {@code computeTemplateResource} 后两个参数是
* (resourceName=template+suffix, characterEncoding),非 suffix/markuper——路径由 template 自行拼接。
*/
@Slf4j
@Configuration
public class ThemeTemplateResolverConfig {
@Bean
@org.springframework.core.annotation.Order(Ordered.HIGHEST_PRECEDENCE)
public ITemplateResolver externalThemeTemplateResolver(final ThemeRegistry registry,
final org.springframework.beans.factory.ObjectProvider<org.thymeleaf.TemplateEngine> engineProvider) {
FileTemplateResolver resolver = new FileTemplateResolver() {
@Override
protected ITemplateResource computeTemplateResource(IEngineConfiguration configuration,
String ownerTemplate, String template,
String resourceName, String characterEncoding,
java.util.Map<String, Object> templateResolutionAttributes) {
String t = template.replace('\\', '/');
if (t.startsWith("themes/") && !t.equals("themes/")) {
String rest = t.substring("themes/".length());
int slash = rest.indexOf('/');
String pkg = slash > 0 ? rest.substring(0, slash) : rest;
if (ThemeRegistry.SOURCE_EXTERNAL.equals(registry.sourceOf(pkg))) {
String rel = (rest.endsWith(".html") ? rest : rest + ".html")
.replace('/', java.io.File.separatorChar);
String path = Paths.get(normalize(registry.getThemeDir()), rel).toString();
return new FileTemplateResource(path, characterEncoding);
}
}
// 内置包名等:返回不存在资源(checkExistence=true → 本解析器跳过,回落 classpath 解析器)
return new FileTemplateResource("__nonexistent_external__/themes/" + t, characterEncoding);
}
};
resolver.setCharacterEncoding("UTF-8");
resolver.setCheckExistence(true);
resolver.setCacheable(true);
registry.addReloadListener(() -> {
try {
org.thymeleaf.TemplateEngine engine = engineProvider.getIfAvailable();
if (engine != null) {
engine.clearTemplateCache();
}
} catch (Exception e) {
log.warn("[cms] reload 清模板缓存失败:{}", e.getMessage());
}
});
log.info("[cms] 外部主题模板解析器已注册(仅外部包命中,@Order 最优先,内置包跳过回落 classpath)");
return resolver;
}
private static String normalize(String dir) {
String d = dir == null ? "" : dir.trim().replace('\\', '/');
while (d.endsWith("/")) {
d = d.substring(0, d.length() - 1);
}
return d;
}
}
@@ -0,0 +1,56 @@
package com.yunhai.cms.config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.filter.ForwardedHeaderFilter;
import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.io.File;
/**
* Web 静态资源装配。
*
* <ul>
* <li>{@code /upload/**} → FILE_PATH 本地磁盘(正文图片/轮播图/友链 Logo,双 key 约定方案 §7.3);</li>
* <li>{@code /admin/**} → 管理端 SPA(front 构建产物拷入 classpath:/static/admin/,
* hash 路由 + 相对 base,天然支持子路径部署);</li>
* <li>根路径与 /admin 跳管理端入口。</li>
* </ul>
* 门户主题资产不在此处:按站点皮肤包解析,见 PortalAssetController。
*/
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Value("${FILE_PATH:./upload/}")
private String filePath;
/**
* X-Forwarded-Proto/Host/Port 头处理(B-2):反代(Caddy/nginx)形态下
* request.getScheme()/getServerName()/getServerPort() 修正为对外值,
* 使 public_base_url/base-origin 均未配置时的请求派生 URL 不携带内网 scheme/端口。
* 仅应在可信反代之后部署(直连暴露时该头可伪造,只影响派生形态展示,不影响数据)。
*/
@Bean
public ForwardedHeaderFilter forwardedHeaderFilter() {
return new ForwardedHeaderFilter();
}
@Override
public void addResourceHandlers(ResourceHandlerRegistry registry) {
String root = filePath.endsWith("/") || filePath.endsWith("\\") ? filePath : filePath + "/";
registry.addResourceHandler("/upload/**")
.addResourceLocations("file:" + new File(root).getAbsolutePath().replace('\\', '/') + "/");
registry.addResourceHandler("/admin/**")
.addResourceLocations("classpath:/static/admin/");
}
@Override
public void addViewControllers(ViewControllerRegistry registry) {
registry.addRedirectViewController("/", "/admin/index.html");
registry.addRedirectViewController("/admin", "/admin/index.html");
registry.addRedirectViewController("/admin/", "/admin/index.html");
}
}
@@ -0,0 +1,56 @@
package com.yunhai.cms.controller.api;
import com.yunhai.platform.apistd.ApiResponse;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.convert.ConversionFailedException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
/**
* 管理端参数级异常翻译(体检 H-1/H-4 根因修复)。
*
* <p>平台 GlobalExceptionHandler 只兜 @Valid 校验/BizException/全局 Exception,
* 三类 Spring MVC 参数绑定异常(缺参/类型不匹配/请求体不可读)落入兜底分支被
* 报成 code=6 "内部服务错误"。本 advice 以更高优先级把它们翻译回 code=2 参数错误,
* 前端可提示、日志不再噪音;真正的业务/系统异常不受影响(仍走平台处理器,堆栈完整)。
*/
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE)
@RestControllerAdvice(basePackages = "com.yunhai.cms.controller.api")
public class ApiParamExceptionHandler {
@ExceptionHandler(MissingServletRequestParameterException.class)
public ApiResponse<Object> missingParam(MissingServletRequestParameterException e) {
log.warn("管理端缺必填参数: {}", e.getParameterName());
return ApiResponse.fail(ErrorCode.PARAM_ERROR, "缺少必填参数:" + e.getParameterName());
}
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
public ApiResponse<Object> typeMismatch(MethodArgumentTypeMismatchException e) {
String expected = e.getRequiredType() == null ? "" : "(应为 " + e.getRequiredType().getSimpleName() + ")";
log.warn("管理端参数类型不匹配: {}={}", e.getName(), e.getValue());
return ApiResponse.fail(ErrorCode.PARAM_ERROR, "参数格式不正确:" + e.getName() + expected);
}
/** JSON 解析失败:编码/语法/时间格式(jackson date-format yyyy-MM-dd HH:mm:ss) */
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Object> unreadable(HttpMessageNotReadableException e) {
Throwable cause = e.getCause() == null ? e : e.getCause();
String hint = cause.getMessage() == null ? "" : ": " + firstLine(cause.getMessage());
log.warn("管理端请求体不可读 {}", firstLine(hint));
return ApiResponse.fail(ErrorCode.PARAM_ERROR,
"请求体格式错误(JSON/UTF-8/时间格式,时间需 yyyy-MM-dd HH:mm:ss)");
}
private static String firstLine(String s) {
int i = s.indexOf('\n');
String line = i < 0 ? s : s.substring(0, i);
return line.length() > 120 ? line.substring(0, 120) : line;
}
}
@@ -0,0 +1,78 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.ArticleSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.query.ArticlePageQuery;
import com.yunhai.cms.model.vo.ArticleEditVo;
import com.yunhai.cms.service.impl.ArticleService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
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.validation.Valid;
import java.util.Map;
/**
* 文章管理(方案 §9.2):分页/保存(jsoup 清洗)/发布/下架/置顶/删除。
*/
@RestController
@RequestMapping("/api/article")
@RequiredArgsConstructor
public class ArticleController {
private final ArticleService articleService;
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept, ArticlePageQuery query) {
return articleService.page(authUserWithDept, query);
}
/** 编辑回显(正文/扩展列) */
@GetMapping("/get")
public ArticleEditVo get(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam("id") Integer id) {
return articleService.getEdit(authUserWithDept, siteId, id);
}
/** 保存草稿(content 先过 jsoup 白名单) */
@PostMapping("/save")
public Integer save(BoaUser authUserWithDept, @Valid @RequestBody ArticleSaveRequest request) {
return articleService.save(authUserWithDept, request);
}
/** 发布(publish_time 为未来时间即定时发布;作者自动从 CAS 会话填充) */
@PostMapping("/publish/{id}")
public void publish(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
articleService.publish(authUserWithDept, siteId, id);
}
@PostMapping("/offline/{id}")
public void offline(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId) {
articleService.offline(authUserWithDept, siteId, id);
}
@PostMapping("/topmost/{id}")
public void topmost(BoaUser authUserWithDept,
@PathVariable("id") Integer id,
@RequestParam("siteId") Integer siteId,
@RequestParam("enable") boolean enable) {
articleService.topmost(authUserWithDept, siteId, id, enable);
}
/** 删除(附件转孤儿,附件管理页清理) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
articleService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,57 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.AttachmentVo;
import com.yunhai.cms.service.impl.AttachmentService;
import com.yunhai.platform.utils.model.BoaUser;
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 org.springframework.web.multipart.MultipartFile;
import javax.validation.Valid;
import java.util.Map;
/**
* 附件管理(方案 §9.2):编辑器图片/附件/轮播图/友链 Logo 复用同一上传接口。
*/
@RestController
@RequestMapping("/api/attachment")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
/** 上传(multipart:file 必填;articleId 可空;scene 可空 banner/link/site/category(category=单页栏目贴图),B2 初步标记) */
@PostMapping("/upload")
public AttachmentVo upload(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "articleId", required = false) Integer articleId,
@RequestParam(value = "scene", required = false) String scene,
@RequestParam("file") MultipartFile file) {
return attachmentService.upload(authUserWithDept, siteId, articleId, scene, file);
}
/** 附件列表(orphanOnly=true 仅孤儿) */
@GetMapping("/page")
public Map<String, Object> page(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "orphanOnly", required = false,
defaultValue = "false") boolean orphanOnly,
@RequestParam(value = "page", required = false,
defaultValue = "1") Integer page,
@RequestParam(value = "pageSize", required = false,
defaultValue = "10") Integer pageSize) {
return attachmentService.page(authUserWithDept, siteId, orphanOnly, page, pageSize);
}
/** 删除附件记录(物理文件运维定期清理) */
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
attachmentService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,32 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.config.CmsAuthProperties;
import com.yunhai.cms.model.vo.AuthUserVo;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 管理端登录态(CAS 会话用户镜像,方案 §7.2)。
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final SiteService siteService;
private final CmsAuthProperties authProperties;
@GetMapping("/user")
public AuthUserVo user(BoaUser authUserWithDept) {
boolean superAdmin = authProperties.isSuperAdmin(authUserWithDept);
List<SiteVo> sites = siteService.listFor(authUserWithDept);
return AuthUserVo.of(authUserWithDept, superAdmin, sites);
}
}
@@ -0,0 +1,46 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.BannerSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.BannerVo;
import com.yunhai.cms.service.impl.BannerService;
import com.yunhai.platform.utils.model.BoaUser;
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 javax.validation.Valid;
import java.util.List;
/**
* 轮播/推荐位管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/banner")
@RequiredArgsConstructor
public class BannerController {
private final BannerService bannerService;
@GetMapping("/list")
public List<BannerVo> list(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId,
@RequestParam(value = "position", required = false) String position) {
return bannerService.list(authUserWithDept, siteId, position);
}
/** 保存(校验 link_type/link_value 匹配且属本站) */
@PostMapping("/save")
public BannerVo save(BoaUser authUserWithDept, @Valid @RequestBody BannerSaveRequest request) {
return bannerService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
bannerService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,41 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.service.impl.CmsUserCenterService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 平台代理域接口(Notice BaseApiController 同款契约):GetUserInfo / GetUserMod。
* 返回裸对象,由平台 ApiResponseAdvice 自动包装为 {code, msg, data}。
*/
@RestController
@RequestMapping("/api/base")
@RequiredArgsConstructor
public class BaseApiController {
private final CmsUserCenterService userCenterService;
/**
* 获取当前登录用户信息(CAS 会话注入;前端 layout 取姓名/头像)。
*/
@GetMapping("GetUserInfo")
public BoaUser getUserInfo(BoaUser authUserWithDept) {
return authUserWithDept;
}
/**
* 获取当前用户已授权的模块列表(左侧菜单树)。
*/
@GetMapping("GetUserMod")
public List<Map<String, Object>> getUserMod(BoaUser authUserWithDept) {
Integer schId = authUserWithDept.getCurrentRole() != null
? authUserWithDept.getCurrentRole().getSchId() : null;
return userCenterService.getUserModuleTree(authUserWithDept.getId(), schId);
}
}
@@ -0,0 +1,53 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.CategorySaveRequest;
import com.yunhai.cms.model.dto.CategorySortRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.CategoryTreeNode;
import com.yunhai.cms.service.impl.CategoryService;
import com.yunhai.platform.utils.model.BoaUser;
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.List;
import javax.validation.Valid;
/**
* 栏目管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/category")
@RequiredArgsConstructor
public class CategoryController {
private final CategoryService categoryService;
@GetMapping("/tree")
public List<CategoryTreeNode> tree(BoaUser authUserWithDept,
@RequestParam("siteId") Integer siteId) {
return categoryService.tree(authUserWithDept, siteId);
}
@PostMapping("/save")
public CategoryTreeNode save(BoaUser authUserWithDept,
@Valid @RequestBody CategorySaveRequest request) {
return categoryService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
categoryService.delete(authUserWithDept, request);
}
/** 子栏目排序(UserBase SortChildren 同语义:按传入顺序落库 sortNo=1..n) */
@PostMapping("/sort")
public void sort(BoaUser authUserWithDept, @Valid @RequestBody CategorySortRequest request) {
categoryService.sort(authUserWithDept, request);
}
}
@@ -0,0 +1,43 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.LinkSaveRequest;
import com.yunhai.cms.model.vo.LinkVo;
import com.yunhai.cms.service.impl.LinkService;
import com.yunhai.platform.utils.model.BoaUser;
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 javax.validation.Valid;
import java.util.List;
/**
* 友情链接管理(方案 §9.2)。
*/
@RestController
@RequestMapping("/api/link")
@RequiredArgsConstructor
public class LinkController {
private final LinkService linkService;
@GetMapping("/list")
public List<LinkVo> list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) {
return linkService.list(authUserWithDept, siteId);
}
@PostMapping("/save")
public LinkVo save(BoaUser authUserWithDept, @Valid @RequestBody LinkSaveRequest request) {
return linkService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
linkService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,43 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.dto.PersonSaveRequest;
import com.yunhai.cms.model.vo.PersonVo;
import com.yunhai.cms.service.impl.PersonService;
import com.yunhai.platform.utils.model.BoaUser;
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 javax.validation.Valid;
import java.util.List;
/**
* 名师风采人物管理(docs/20 D-7,people 区块数据源)。
*/
@RestController
@RequestMapping("/api/person")
@RequiredArgsConstructor
public class PersonController {
private final PersonService personService;
@GetMapping("/list")
public List<PersonVo> list(BoaUser authUserWithDept, @RequestParam("siteId") Integer siteId) {
return personService.list(authUserWithDept, siteId);
}
@PostMapping("/save")
public PersonVo save(BoaUser authUserWithDept, @Valid @RequestBody PersonSaveRequest request) {
return personService.save(authUserWithDept, request);
}
@PostMapping("/delete")
public void delete(BoaUser authUserWithDept, @Valid @RequestBody DeleteRequest request) {
personService.delete(authUserWithDept, request);
}
}
@@ -0,0 +1,35 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.vo.SchoolVo;
import com.yunhai.cms.service.impl.SchoolService;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 学校工作台(docs/09 §3.3):一级页面=学校列表,二级 /sch/{code}/** 为学校专属管理壳。
* 权限语义与 /api/site/list 一致;写操作仍走 /api/site/save(添加学校=初始化主站)。
*/
@RestController
@RequestMapping("/api/school")
@RequiredArgsConstructor
public class SchoolController {
private final SchoolService schoolService;
/** 学校工作台列表(超管=全部顶层站点;校级=本校;无角色=空) */
@GetMapping("/list")
public List<SchoolVo> list(BoaUser authUserWithDept) {
return schoolService.listFor(authUserWithDept);
}
/** UserBase 学校选项(添加学校绑定用,仅超管;平台不可达降级空数组) */
@GetMapping("/options")
public List<SchoolVo.Option> options(BoaUser authUserWithDept) {
return schoolService.schoolOptions(authUserWithDept);
}
}
@@ -0,0 +1,39 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.dto.SiteSaveRequest;
import com.yunhai.cms.model.vo.SiteVo;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.platform.utils.model.BoaUser;
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.RestController;
import java.util.List;
import javax.validation.Valid;
/**
* 站点管理(超管,方案 §9.1/§9.2)。
*/
@RestController
@RequestMapping("/api/site")
@RequiredArgsConstructor
public class SiteController {
private final SiteService siteService;
/** 站点列表(超管=全部;校级=本校站点) */
@GetMapping("/list")
public List<SiteVo> list(BoaUser authUserWithDept) {
return siteService.listFor(authUserWithDept);
}
/** 站点保存(仅超管) */
@PostMapping("/save")
public SiteVo save(BoaUser authUserWithDept, @Valid @RequestBody SiteSaveRequest request) {
return siteService.save(authUserWithDept, request);
}
}
@@ -0,0 +1,140 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.theme.ThemeInfo;
import com.yunhai.cms.theme.ThemePackageService;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.Resource;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import com.yunhai.platform.utils.model.BoaUser;
import javax.servlet.http.HttpServletRequest;
import java.time.Duration;
import java.util.List;
/**
* 主题管理接口(方案 §5.2,路径对齐 /api/{domain} 约定):
* /api/theme/list 主题列表;/api/theme/{name}/asset/** 主题资产代理
* (screenshot 与预览资产共用,防穿越同 PortalAssetController,name 先过 Registry 白名单)。
* 走 /api/* CAS 鉴权(现状边界)。
*/
@RestController
@RequestMapping("/api/theme")
@RequiredArgsConstructor
public class ThemeController {
private static final Duration ASSET_CACHE = Duration.ofHours(1);
private final ThemeService themeService;
private final ThemeRegistry themeRegistry;
private final ThemePackageService themePackageService;
@GetMapping("/list")
public List<com.yunhai.cms.model.vo.ThemeVo> list() {
return themeService.list();
}
/** W1(G2):上传合规 zip——立即注册(免重启);不合规返回逐条校验报告,不落盘不注册。
* 任务四:title/description 选填,落位前改写包内 theme.json(留空沿用包内声明) */
@PostMapping("/upload")
public com.yunhai.cms.model.vo.ThemeVo upload(BoaUser authUserWithDept,
@RequestParam("file") MultipartFile file,
@RequestParam(value = "title", required = false) String title,
@RequestParam(value = "description", required = false) String description) {
ThemeInfo info = themePackageService.upload(authUserWithDept, file, title, description);
return themeService.voOf(info);
}
/** W1(G3):从现有包派生新皮肤(复制包体+改名,外部包可编辑可删除);任务四支持同步命名 */
@PostMapping("/derive")
public com.yunhai.cms.model.vo.ThemeVo derive(BoaUser authUserWithDept,
@RequestBody com.yunhai.cms.model.dto.ThemeDeriveRequest req) {
ThemeInfo info = themePackageService.derive(authUserWithDept, req.getSource(), req.getName(),
req.getTitle(), req.getDescription());
return themeService.voOf(info);
}
/** W1(G4)+ 任务四:编辑元数据——外部包写 theme.json(title/描述/参数 schema),
* 内置包显示名/描述写 .meta 覆盖层(参数 schema 拒改),保存后引用站点立即生效。
* 工作项 T:paramValues(包级参数值)两种来源统一写 .meta 覆盖层,null=不改 */
@PutMapping("/{name}/meta")
public com.yunhai.cms.model.vo.ThemeVo updateMeta(BoaUser authUserWithDept,
@PathVariable("name") String name,
@RequestBody com.yunhai.cms.model.dto.ThemeMetaRequest req) {
ThemeInfo info = themePackageService.updateMeta(authUserWithDept, name,
req.getTitle(), req.getDescription(), req.getParams(), req.getParamValues());
return themeService.voOf(info);
}
/** W1(G5):删除外部包(被用站点拒绝;内置不可删) */
@DeleteMapping("/{name}")
public java.util.Map<String, Object> remove(BoaUser authUserWithDept, @PathVariable("name") String name) {
themePackageService.remove(authUserWithDept, name);
java.util.Map<String, Object> result = new java.util.HashMap<>();
result.put("ok", true);
return result;
}
/** W1(G2/B4):热重载(运维直放目录后生效;default 缺失报错且旧集合不丢) */
@PostMapping("/reload")
public List<com.yunhai.cms.model.vo.ThemeVo> reload(BoaUser authUserWithDept) {
themePackageService.reload(authUserWithDept);
return themeService.list();
}
@GetMapping("/{name}/asset/**")
public ResponseEntity<Resource> asset(@PathVariable("name") String name,
HttpServletRequest request) {
if (!themeRegistry.contains(name)) {
return ResponseEntity.notFound().build();
}
String path = extractRelativePath(request, name);
if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build();
}
Resource resource = themeRegistry.resource(name, "assets/" + path);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
MediaType mediaType = MediaTypeFactory.getMediaType(path)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic())
.body(resource);
}
/**
* 从请求 URI 截取 /asset/ 之后的相对路径(同 PortalAssetController:
* 直接解析 URL-decode 后的 URI,兼容两种路径匹配器语义)。
*/
private String extractRelativePath(HttpServletRequest request, String name) {
String uri = request.getRequestURI();
String prefix = request.getContextPath() + "/api/theme/" + name + "/asset/";
int index = uri.indexOf(prefix);
if (index < 0) {
return null;
}
String relative = uri.substring(index + prefix.length());
try {
relative = java.net.URLDecoder.decode(relative, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
return relative.replace('\\', '/');
}
}
@@ -0,0 +1,72 @@
package com.yunhai.cms.controller.api;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.SiteService;
import com.yunhai.cms.service.impl.ThemeService;
import com.yunhai.cms.theme.ThemeInfo;
import com.yunhai.cms.theme.ThemeVarsCss;
import com.yunhai.cms.controller.portal.PortalPageRenderer;
import com.yunhai.cms.utils.PortalUrlHelper;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.Map;
/**
* 主题预览(方案 §5.5):管理端鉴权的代理渲染接口,iframe 同源加载。
* 预览渲染用站点快照的浅拷贝覆盖 theme/themeParams,资产 URL 切换到管理端代理
* (防预览资产串包,R1);不在匿名门户 URL 开任何覆盖参数。
* B-6:page 白名单(P1 仅 home,非法值明确报错);内容源站点过可管理校验(requireManageable)。
*/
@Controller
@RequestMapping("/api/theme")
@RequiredArgsConstructor
public class ThemePreviewController {
private final ThemeService themeService;
private final PortalQueryService portalQueryService;
private final PortalPageRenderer pageRenderer;
private final SiteService siteService;
/** 预览首页(page 参数 P1 仅支持 home):no-store 防止进缓存/CDN(方案 §10 风险表)。
* W4:内容源站点参数 siteCode→sitePath(嵌套路径语义,siteSnapshotByPath 解析)。 */
@GetMapping("/preview")
public String preview(@RequestParam("sitePath") String sitePath,
@RequestParam("theme") String theme,
@RequestParam(value = "page", required = false, defaultValue = "home") String page,
BoaUser authUserWithDept,
HttpServletRequest request, HttpServletResponse response, Model model) {
response.setHeader("Cache-Control", "no-store");
// B-6①:page 白名单——非法值明确报错,不静默当 home 渲染(接口语义误导)
if (!"home".equals(page)) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的预览页面:" + page + "(当前仅支持 home)");
}
ThemeInfo info = themeService.requireRegistered(theme);
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) {
throw new BizException(ErrorCode.PARAM_ERROR,
"内容源站点不存在或已停用:" + sitePath);
}
// B-6②:预览渲染的是内容源站点数据快照,与管理端同权限级(requireManageable)
if (authUserWithDept == null) {
throw new BizException(ErrorCode.FORBIDDEN, "未登录或会话失效,无法预览");
}
siteService.requireManageable(authUserWithDept, site.getId());
PortalSiteSnapshot previewSite = site.shallowCopy();
previewSite.setTheme(theme);
Map<String, String> params = themeService.extractPreviewParams(info, request.getParameterMap());
previewSite.setThemeParams(params);
previewSite.setThemeVarsCss(ThemeVarsCss.build(info, params));
return pageRenderer.renderHome(previewSite, PortalUrlHelper.forPreview(request, sitePath, theme), model);
}
}
@@ -0,0 +1,87 @@
package com.yunhai.cms.controller.oapi.site;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.utils.ExtJsonUtils;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import lombok.Data;
import lombok.RequiredArgsConstructor;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 站点目录开放接口(/oapi/site/list,appKey 签名访问;V2.0 Part C.3)。
* 消费方=办事大厅 svc-hall(CmsSiteDirectoryImpl:resolveSite 反解 siteCode→schId、
* 站点选择器按校拉取;本地缓存 5min)。schId 可选:
* 不传=全部启用站点(跨校全量,供 siteCode 反解 siteCode→schId,旧办件仍可定位);
* 传了=按校过滤且<b>仅主站</b>(parentSiteId=0):院系子站不接入办事大厅(2026-09-17 口径收窄,
* 办事大厅按校接入一间学校只对应主站一个站点),SiteOapiIT 冻结该契约。
*/
@RestController
@RequestMapping("/oapi/site")
@RequiredArgsConstructor
public class SiteOapiController {
private final CmsSiteRepository siteRepository;
@GetMapping("/list")
public List<SiteOptionVo> list(@RequestParam(value = "schId", required = false) Integer schId) {
if (schId == null) {
return siteRepository.findByStatusOrderByIdAsc(1).stream()
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
if (schId <= 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "schId 非法");
}
return siteRepository.findBySchId(schId).stream()
.filter(site -> site.getStatus() != null && site.getStatus() == 1)
// 仅主站:院系子站不接入办事大厅(反解全量不受影响)
.filter(site -> site.getParentSiteId() == null || site.getParentSiteId() == 0)
.map(SiteOapiController::toVo)
.collect(java.util.stream.Collectors.toList());
}
private static SiteOptionVo toVo(CmsSite site) {
SiteOptionVo vo = new SiteOptionVo();
vo.setSiteCode(site.getCode());
vo.setName(site.getName());
vo.setSchId(site.getSchId() == null ? null : site.getSchId().longValue());
// T1 主题联动(2026-10-03):theme 空=default;themeParams=config_json.theme_params
// 原样键值(解析写法对齐 PortalSiteSnapshot.of),未配=空对象(消费方免判空两写)
vo.setTheme(site.getTheme() == null || site.getTheme().isEmpty()
? "default" : site.getTheme());
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
Object tp = config == null ? null : config.get("theme_params");
Map<String, String> params = new LinkedHashMap<>();
if (tp instanceof Map) {
for (Map.Entry<?, ?> e : ((Map<?, ?>) tp).entrySet()) {
if (e.getKey() != null && e.getValue() != null) {
params.put(String.valueOf(e.getKey()), String.valueOf(e.getValue()));
}
}
}
vo.setThemeParams(params);
return vo;
}
/** 站点选项(siteCode 为办事大厅侧定位键;schId 用于权限与租户定界;
* theme/themeParams raw 透传——消费方(svc-hall hall 用户面)按皮肤声明自行过滤) */
@Data
public static class SiteOptionVo {
private String siteCode;
private String name;
private Long schId;
/** CMS 皮肤包名(空已兜底为 default) */
private String theme;
/** CMS config_json.theme_params 原样键值(未配置=空对象) */
private Map<String, String> themeParams = new LinkedHashMap<>();
}
}
@@ -0,0 +1,100 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.theme.ThemeRegistry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.CacheControl;
import org.springframework.http.MediaType;
import org.springframework.http.MediaTypeFactory;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.core.io.Resource;
import javax.servlet.http.HttpServletRequest;
import java.time.Duration;
/**
* 门户主题资产(方案 §8.2/§8.6):/portal/{siteCode}/assets/{theme}/** 解析到
* classpath:/templates/themes/{theme}/assets/**。URL 由 PortalUrlHelper 以
* public_base_url 为基准生成并携带主题段(B-3),学校域名形态同样命中本控制器。
*
* <p>主题段取自 URL 而非站点当前皮肤:换肤后 URL 即变,浏览器/CDN 对旧 URL 的
* max-age 缓存天然失效;旧 URL 本身仍可服务(URL 与内容一一对应,缓存语义安全)。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class PortalAssetController {
private static final Duration ASSET_CACHE = Duration.ofHours(1);
private final PortalQueryService portalQueryService;
private final ThemeRegistry themeRegistry;
@GetMapping("/portal/{siteCode}/assets/{theme}/**")
public ResponseEntity<Resource> asset(@PathVariable("siteCode") String siteCode,
@PathVariable("theme") String theme,
HttpServletRequest request) {
return doAsset(siteCode, theme, request);
}
/** W4 嵌套变体:/portal/{p}/{s}/assets/{theme}/**(层级固定两层) */
@GetMapping("/portal/{p}/{s}/assets/{theme}/**")
public ResponseEntity<Resource> assetNested(@PathVariable("p") String p,
@PathVariable("s") String s,
@PathVariable("theme") String theme,
HttpServletRequest request) {
return doAsset(p + "/" + s, theme, request);
}
private ResponseEntity<Resource> doAsset(String sitePath, String theme, HttpServletRequest request) {
// 主题段必须已注册(兼防路径注入:未注册名一律 404)
if (!themeRegistry.contains(theme)) {
return ResponseEntity.notFound().build();
}
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site == null) {
return ResponseEntity.notFound().build();
}
String path = extractRelativePath(request, sitePath, theme);
if (path == null || path.isEmpty() || path.contains("..")) {
return ResponseEntity.badRequest().build();
}
// W1(T-W1-20):内置→ClassPathResource、外部→FileSystemResource,统一走 Registry 定位
Resource resource = themeRegistry.resource(theme, "assets/" + path);
if (!resource.exists()) {
return ResponseEntity.notFound().build();
}
MediaType mediaType = MediaTypeFactory.getMediaType(path)
.orElse(MediaType.APPLICATION_OCTET_STREAM);
return ResponseEntity.ok()
.contentType(mediaType)
.cacheControl(CacheControl.maxAge(ASSET_CACHE).cachePublic())
.body(resource);
}
/**
* 从请求 URI 截取 /assets/{theme}/ 之后的相对路径。
* 直接解析 URI(URL-decode 后),兼容 AntPathMatcher 与 PathPatternParser 两种匹配器语义。
* W4:前缀用完整 sitePath(嵌套形态含两层段)。
*/
private String extractRelativePath(HttpServletRequest request, String sitePath, String theme) {
String uri = request.getRequestURI();
String prefix = request.getContextPath() + "/portal/" + sitePath + "/assets/" + theme + "/";
int index = uri.indexOf(prefix);
if (index < 0) {
return null;
}
String relative = uri.substring(index + prefix.length());
try {
relative = java.net.URLDecoder.decode(relative, "UTF-8");
} catch (java.io.UnsupportedEncodingException e) {
return null;
}
return relative.replace('\\', '/');
}
}
@@ -0,0 +1,814 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsSiteRepository;
import com.yunhai.cms.service.impl.PortalQueryService;
import com.yunhai.cms.service.impl.ViewCounterService;
import com.yunhai.cms.svcitem.model.query.SvcItemPortalQuery;
import com.yunhai.cms.svcitem.model.vo.PortalServiceFilters;
import com.yunhai.cms.svcitem.model.vo.PortalServiceItem;
import com.yunhai.cms.svcitem.model.vo.PortalServiceListData;
import com.yunhai.cms.svcitem.service.SvcItemQuery;
import com.yunhai.cms.utils.PortalUrlHelper;
import com.yunhai.cms.utils.SvcHallLinks;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestParam;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 门户 SSR(方案 §8,匿名可访问——CAS url-pattern 只配 /api/*,勿改)。
* 站点身份来自路径不依赖 Host(方案 §8.6);所有链接经 PortalUrlHelper 按
* public_base_url 生成,base 域子路径与学校独立域名两种暴露形态共用渲染。
*/
@Slf4j
@Controller
@RequiredArgsConstructor
public class PortalController {
/** 首页每个一级栏目取 N 条 */
private static final int HOME_BLOCK_SIZE = 8;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
/** 指南页"相关事项"取 N 条(事项库 §5.3) */
private static final int RELATED_ITEMS_SIZE = 6;
private final PortalQueryService portalQueryService;
private final CmsArticleRepository articleRepository;
private final CmsSiteRepository siteRepository;
private final ViewCounterService viewCounterService;
private final PortalPageRenderer pageRenderer;
private final SvcItemQuery svcItemQuery;
@Value("${cms.portal.detail-max-age:300}")
private int detailMaxAge;
/** base 域对外基地址(C-6:public_base_url 未配置时的规范派生基准;空=按请求派生) */
@Value("${cms.portal.base-origin:}")
private String baseOrigin;
/** 首页:站点配置 + home 轮播位 + 各一级栏目 top N + 置顶文章 + 友链分组(装配在 PortalPageRenderer,方案 §5.5)。
* W4:单段/嵌套双映射收敛到 doHome(sitePath 为唯一身份)。 */
@GetMapping("/portal/{siteCode}/")
public String home(@PathVariable("siteCode") String siteCode, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
return doHome(siteCode, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/(层级固定两层) */
@GetMapping("/portal/{p}/{s}/")
public String homeNested(@PathVariable("p") String p, @PathVariable("s") String s,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doHome(p + "/" + s, request, response, model);
}
private String doHome(String sitePath, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "", request, response);
if (site == null) {
return null;
}
return pageRenderer.renderHome(site, buildUrl(site, request), model);
}
/** 栏目页(列表/单页);外链栏目 302(方案 §8.1) */
@GetMapping("/portal/{siteCode}/c/{categoryId}")
public String category(@PathVariable("siteCode") String siteCode,
@PathVariable("categoryId") Integer categoryId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doCategory(siteCode, categoryId, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/c/{categoryId} */
@GetMapping("/portal/{p}/{s}/c/{categoryId}")
public String categoryNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("categoryId") Integer categoryId,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doCategory(p + "/" + s, categoryId, page, request, response, model);
}
private String doCategory(String sitePath, Integer categoryId, Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "c/" + categoryId, request, response);
if (site == null) {
return null;
}
PortalCategory category = findCategory(site, categoryId);
if (category == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
if (category.getType() != null && category.getType() == CategoryTypeEnum.LINK.getCode()) {
PortalUrlHelper fallback = buildUrl(site, request);
response.sendRedirect(category.getLinkUrl() == null ? fallback.home() : category.getLinkUrl());
return null;
}
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", categoryId);
model.addAttribute("category", category);
// 栏目导航三件套(docs/11):面包屑全路径 + 顶导航祖先高亮 + 左侧兄弟侧栏
pageRenderer.attachCategoryNavigation(site, categoryId, model);
model.addAttribute("pageTitle", seoTitle(category, site));
model.addAttribute("seoDescription", category.getSeoDescription());
model.addAttribute("canonical", url.category(categoryId));
if (category.getType() != null && category.getType() == CategoryTypeEnum.SINGLE.getCode()) {
// 单页正文上传地址渲染期补基准(B-1):拷贝后重写,不污染快照缓存体
model.addAttribute("category", category.withContent(
url.rewriteUploadUrls(category.getContent())));
return "themes/" + site.getTheme() + "/single";
}
PortalListData listData = portalQueryService.categoryPage(categoryId, page, 10);
if (listData == null) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
model.addAttribute("listData", listData);
model.addAttribute("searchMode", false);
return "themes/" + site.getTheme() + "/list";
}
/** 文章详情:正文 th:utext + 上下篇 + 浏览量异步累加 + Cache-Control(方案 §8.3) */
@GetMapping("/portal/{siteCode}/a/{articleId}")
public String article(@PathVariable("siteCode") String siteCode,
@PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doArticle(siteCode, articleId, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/a/{articleId} */
@GetMapping("/portal/{p}/{s}/a/{articleId}")
public String articleNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("articleId") Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doArticle(p + "/" + s, articleId, request, response, model);
}
private String doArticle(String sitePath, Integer articleId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "a/" + articleId, request, response);
if (site == null) {
return null;
}
com.yunhai.cms.model.portal.PortalArticleDetail detail =
portalQueryService.articleDetail(articleId);
if (detail == null || !detail.getSiteId().equals(site.getId())) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
viewCounterService.increment(articleId);
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("currentCategoryId", detail.getCategoryId());
// 面包屑栏目名(视觉精修 D2-2):按栏目树查名,查不到(栏目已删)退"正文"
PortalCategory detailCategory = findCategory(site, detail.getCategoryId());
model.addAttribute("currentCategoryName", detailCategory == null ? "正文" : detailCategory.getName());
// 栏目导航三件套(docs/11):详情页同栏目页规则;栏目已删时 path 空,模板按缺省形态渲染
pageRenderer.attachCategoryNavigation(site, detail.getCategoryId(), model);
// 正文上传地址渲染期补基准(B-1):详情来自 cms:article 缓存(快照跨两种暴露形态共享),
// 拷贝后重写,避免把某一形态的绝对 URL 写回缓存
model.addAttribute("article", detail.withContent(
url.rewriteUploadUrls(detail.getContent())));
// og:image(C-7):文章封面(无封面则不输出该标签)
model.addAttribute("ogImage", url.upload(detail.getCoverImage()));
model.addAttribute("pageTitle", detail.getTitle() + " - " + site.getName());
model.addAttribute("seoDescription", detail.getSummary());
model.addAttribute("canonical", url.article(articleId));
model.addAttribute("prevArticle", prevArticle(site.getId(), articleId, detail.getPublishTime()));
model.addAttribute("nextArticle", nextArticle(site.getId(), articleId, detail.getPublishTime()));
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
return "themes/" + site.getTheme() + "/detail";
}
/** 站内搜索:title/summary LIKE(实时查询不缓存) */
@GetMapping("/portal/{siteCode}/search")
public String search(@PathVariable("siteCode") String siteCode,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doSearch(siteCode, q, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/search */
@GetMapping("/portal/{p}/{s}/search")
public String searchNested(@PathVariable("p") String p, @PathVariable("s") String s,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doSearch(p + "/" + s, q, page, request, response, model);
}
private String doSearch(String sitePath, String q, Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "search", request, response);
if (site == null) {
return null;
}
PortalUrlHelper url = buildUrl(site, request);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q);
model.addAttribute("pageTitle", "搜索:" + q + " - " + site.getName());
model.addAttribute("seoDescription", null);
model.addAttribute("canonical", url.search(q, page));
model.addAttribute("searchMode", true);
if (q.trim().isEmpty()) {
model.addAttribute("listData", emptyList(page));
return "themes/" + site.getTheme() + "/list";
}
org.springframework.data.domain.Page<PortalListData.Item> result =
portalQueryService.search(site.getId(), q, page, 10);
PortalListData listData = new PortalListData();
listData.setSiteId(site.getId());
listData.setItems(result.getContent());
listData.setTotal(result.getTotalElements());
listData.setPage(page);
listData.setPageSize(10);
listData.setTotalPages(result.getTotalPages());
model.addAttribute("listData", listData);
return "themes/" + site.getTheme() + "/list";
}
// ===== 内部装配 =====
/**
* 服务中心列表(事项库 §5.1/§5.2):三维筛选 obj/cat/dept + 关键词 q + 分页。
* 筛选/分页 URL 全部后端预生成(C-2:模板禁止手拼 query);service.html 为可选模板,
* 走 M0 整页回退(缺模板时视图名/themeBase/资产主题段统一退 default)。
*/
@GetMapping("/portal/{siteCode}/service")
public String service(@PathVariable("siteCode") String siteCode,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(siteCode, obj, cat, dept, q, page, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/service */
@GetMapping("/portal/{p}/{s}/service")
public String serviceNested(@PathVariable("p") String p, @PathVariable("s") String s,
@RequestParam(value = "obj", required = false) String obj,
@RequestParam(value = "cat", required = false) String cat,
@RequestParam(value = "dept", required = false) Integer dept,
@RequestParam(value = "q", required = false, defaultValue = "") String q,
@RequestParam(value = "page", required = false, defaultValue = "1") Integer page,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doServiceList(p + "/" + s, obj, cat, dept, q, page, request, response, model);
}
/** 事项指南页(§5.3):富文本已清洗(保存期 jsoup)+ 渲染期补基准;applyUrl 三态优先级 */
@GetMapping("/portal/{siteCode}/sv/{itemId}")
public String guide(@PathVariable("siteCode") String siteCode,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(siteCode, itemId, request, response, model);
}
/** W4 嵌套变体:/portal/{p}/{s}/sv/{itemId} */
@GetMapping("/portal/{p}/{s}/sv/{itemId}")
public String guideNested(@PathVariable("p") String p, @PathVariable("s") String s,
@PathVariable("itemId") Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
return doGuide(p + "/" + s, itemId, request, response, model);
}
private String doServiceList(String sitePath, String obj, String cat, Integer dept, String q,
Integer page, HttpServletRequest request,
HttpServletResponse response, Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "service", request, response);
if (site == null) {
return null;
}
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "service.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
applyServiceNavHighlight(site, url, model);
SvcItemPortalQuery query = new SvcItemPortalQuery();
query.setSiteId(site.getId());
query.setObjectKey(trimToNull(obj));
query.setCategoryKey(trimToNull(cat));
query.setDeptGroupId(dept);
query.setKeyword(trimToNull(q));
query.setPage(page == null || page < 1 ? 1 : page);
// P1-4:svc-hall 停机/重启窗口(连接类异常,含 DHHttpException code=0)降级空态渲染 200,
// 不再连坐访客 500;业务性缺失语义维持 §2.3 修复后的行为(portalPage 无 code=2 分支)
PortalServiceListData serviceList;
boolean svcUnavailable;
try {
serviceList = svcItemQuery.portalPage(query);
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(降级空态渲染)siteId={}:{}", site.getId(), e.toString());
serviceList = new PortalServiceListData();
serviceList.setSiteId(site.getId());
serviceList.setPage(1);
serviceList.setPageSize(10);
serviceList.setTotal(0L);
serviceList.setTotalPages(0);
svcUnavailable = true;
}
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
model.addAttribute("canonical", url.serviceList());
return "themes/" + effectiveTheme + "/service";
}
// 筛选/分页 URL 预生成(§5.4):勾选=替换本维、取消=移除本维,其余维度与关键词保留,page 归 1
Map<String, Object> currentFilters = currentServiceFilters(obj, cat, dept, q);
model.addAttribute("serviceFilters",
buildServiceFilters(site.getId(), url, obj, cat, dept, currentFilters));
Map<String, Object> activeFilters = new HashMap<>();
activeFilters.put("obj", trimToNull(obj));
activeFilters.put("cat", trimToNull(cat));
activeFilters.put("dept", dept);
activeFilters.put("q", q == null ? "" : q);
activeFilters.put("clearUrl", url.serviceList());
activeFilters.put("hasAny", trimToNull(obj) != null || trimToNull(cat) != null
|| dept != null || (q != null && !q.trim().isEmpty()));
model.addAttribute("activeFilters", activeFilters);
for (int p = 1; p <= serviceList.getTotalPages(); p++) {
Map<String, Object> pageQuery = new LinkedHashMap<>(currentFilters);
if (p > 1) {
pageQuery.put("page", p);
}
serviceList.getPageUrls().add(new PortalServiceListData.PageUrl(
p, url.serviceList(pageQuery), p == serviceList.getPage()));
}
model.addAttribute("serviceList", serviceList);
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("q", q == null ? "" : q);
model.addAttribute("pageTitle", "服务中心 - " + site.getName());
model.addAttribute("seoDescription", null);
Map<String, Object> canonicalQuery = new LinkedHashMap<>(currentFilters);
if (serviceList.getPage() != null && serviceList.getPage() > 1) {
canonicalQuery.put("page", serviceList.getPage());
}
model.addAttribute("canonical", url.serviceList(canonicalQuery));
return "themes/" + effectiveTheme + "/service";
}
private String doGuide(String sitePath, Integer itemId,
HttpServletRequest request, HttpServletResponse response,
Model model) throws Exception {
PortalSiteSnapshot site = requireSiteByPath(sitePath, "sv/" + itemId, request, response);
if (site == null) {
return null;
}
// P1-4:svc-hall 连接类异常降级空态渲染 200;业务性缺失(code=2→null)维持 404 语义
PortalServiceItem item;
boolean svcUnavailable;
try {
item = svcItemQuery.portalItem(itemId, site.getId());
svcUnavailable = false;
} catch (RuntimeException e) {
log.warn("[portal] 事项服务不可达(指南页降级空态渲染)siteId={} itemId={}:{}",
site.getId(), itemId, e.toString());
item = null;
svcUnavailable = true;
}
if (item == null && !svcUnavailable) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
String effectiveTheme = pageRenderer.resolveEffectiveTheme(site, "guide.html");
PortalUrlHelper url = buildUrl(site, request, effectiveTheme);
applyServiceNavHighlight(site, url, model);
model.addAttribute("svcUnavailable", svcUnavailable);
if (svcUnavailable) {
addChromeAttributes(model, site, request, effectiveTheme, url, "服务中心 - " + site.getName());
return "themes/" + effectiveTheme + "/guide";
}
try {
svcItemQuery.countView(itemId);
} catch (RuntimeException e) {
log.debug("[portal] 浏览量累加失败(忽略)itemId={}:{}", itemId, e.toString());
}
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
// 富文本渲染期补基准(copy-on-write,不污染缓存体;内容已在保存期过 jsoup 白名单)
model.addAttribute("item", item.withRewrittenHtml(
url.rewriteUploadUrls(item.getProcessHtml()),
url.rewriteUploadUrls(item.getMaterialHtml()),
url.rewriteUploadUrls(item.getNoticeHtml())));
model.addAttribute("attachments", attachmentsQuietly(itemId));
List<Map<String, Object>> related = new ArrayList<>();
for (PortalServiceItem rel : relatedQuietly(site.getId(), itemId, item.getCategoryKey())) {
Map<String, Object> row = new HashMap<>();
row.put("id", rel.getId());
row.put("title", rel.getTitle());
row.put("deptName", rel.getDeptName());
row.put("handleMode", rel.getHandleMode());
row.put("url", url.serviceItem(rel.getId()));
related.add(row);
}
model.addAttribute("relatedItems", related);
if (item.getFaqCategoryId() != null) {
model.addAttribute("faqCategoryUrl", url.category(item.getFaqCategoryId()));
}
if (item.getNoticeCategoryId() != null) {
model.addAttribute("noticeCategoryUrl", url.category(item.getNoticeCategoryId()));
}
if (item.getRuleCategoryId() != null) {
model.addAttribute("ruleCategoryUrl", url.category(item.getRuleCategoryId()));
}
// "我要办理"三态(§5.3):① ext_json.apply_url 手填外链(最高优先)
// ② 未填且站点配了 svc_hall_base 且事项开通在线办理(onlineFlag=1 且绑流程,§3.7——
// 与 svc-hall 侧 submit 校验"未开通在线办理"口径对齐,线下/纯指南事项不再渲染按钮)
// ——深链带 from=指南页回跳地址,hall 据此显示"返回门户"按钮(T4 闭环)
// ③ 其余 → 不注入(模板不渲染按钮)
String applyUrl = item.getApplyUrl();
boolean onlineHandlable = item.getOnlineFlag() != null && item.getOnlineFlag() == 1
&& StringUtils.hasText(item.getFlowGuid());
if ((applyUrl == null || applyUrl.trim().isEmpty()) && onlineHandlable
&& site.getSvcHallBase() != null && !site.getSvcHallBase().trim().isEmpty()) {
applyUrl = SvcHallLinks.build(site.getSvcHallBase(), site.getCode(),
item.getItemCode(), url.serviceItem(itemId));
}
if (applyUrl != null && !applyUrl.trim().isEmpty()) {
model.addAttribute("applyUrl", applyUrl);
}
if (item.getCoverImage() != null && !item.getCoverImage().trim().isEmpty()) {
model.addAttribute("ogImage", url.upload(item.getCoverImage()));
}
model.addAttribute("pageTitle", item.getTitle() + " - " + site.getName());
model.addAttribute("seoDescription", item.getSummary());
model.addAttribute("canonical", url.serviceItem(itemId));
response.setHeader("Cache-Control", "max-age=" + detailMaxAge);
return "themes/" + effectiveTheme + "/guide";
}
/**
* 服务中心/指南页的顶导航高亮:服务中心页不是栏目页,currentCategoryId 为空会让
* 皮肤 header 的"首页"误高亮。若站点导航里存在指向本站服务中心列表页的外链栏目
* (type=3,如"服务中心"),则把它视为当前栏目——既有高亮逻辑即同时达成
* "首页不高亮 + 服务中心高亮";找不到匹配栏目时置 navServiceActive,
* 皮肤以该标记单独关闭"首页"高亮(无服务中心入口的站点保持原状)。
*/
private void applyServiceNavHighlight(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
String servicePath = pathOf(url.serviceList());
Integer matched = null;
if (servicePath != null) {
for (PortalCategory cat : site.getCategories()) {
if (isServiceNavLink(cat, servicePath)) {
matched = cat.getId();
break;
}
for (PortalCategory sub : cat.getChildren()) {
if (isServiceNavLink(sub, servicePath)) {
matched = sub.getId();
break;
}
}
if (matched != null) {
break;
}
}
}
if (matched != null) {
model.addAttribute("currentCategoryId", matched);
} else {
model.addAttribute("navServiceActive", true);
}
}
private boolean isServiceNavLink(PortalCategory cat, String servicePath) {
Integer type = cat.getType();
return type != null && type == CategoryTypeEnum.LINK.getCode()
&& servicePath.equals(pathOf(cat.getLinkUrl()));
}
/** 取 URL 的归一化路径:剥 scheme://host / query / fragment / 尾斜杠(相对链接原样返回路径) */
private static String pathOf(String url) {
if (url == null) {
return null;
}
String u = url.trim();
int hash = u.indexOf('#');
if (hash >= 0) {
u = u.substring(0, hash);
}
int query = u.indexOf('?');
if (query >= 0) {
u = u.substring(0, query);
}
while (u.endsWith("/")) {
u = u.substring(0, u.length() - 1);
}
int scheme = u.indexOf("://");
if (scheme >= 0) {
int slash = u.indexOf('/', scheme + 3);
u = slash >= 0 ? u.substring(slash) : "";
}
return u.isEmpty() ? null : u;
}
/** 门户页面公共骨架属性(P1-4 降级路径与正常路径共用;q/seoDescription 等页面特有属性由调用方补) */
private void addChromeAttributes(Model model, PortalSiteSnapshot site, HttpServletRequest request,
String effectiveTheme, PortalUrlHelper url, String pageTitle) {
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + effectiveTheme);
model.addAttribute("navCategories", site.getCategories());
model.addAttribute("pageTitle", pageTitle);
}
/** 附件查询静默降级(svc-hall 抖动不阻断已可渲染的指南页;业务性缺失已在查询层转空) */
private List<com.yunhai.cms.svcitem.model.vo.SvcItemAttachmentVo> attachmentsQuietly(Integer itemId) {
try {
return svcItemQuery.attachments(itemId);
} catch (RuntimeException e) {
log.warn("[portal] 附件查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 相关事项查询静默降级(同上) */
private List<PortalServiceItem> relatedQuietly(Integer siteId, Integer itemId, String categoryKey) {
try {
return svcItemQuery.relatedItems(siteId, itemId, categoryKey, RELATED_ITEMS_SIZE);
} catch (RuntimeException e) {
log.warn("[portal] 相关事项查询失败(降级为空)itemId={}:{}", itemId, e.toString());
return new ArrayList<>();
}
}
/** 当前有效筛选参数(obj/cat/dept/q,空值剔除)——分页与 canonical URL 的公共底稿 */
private Map<String, Object> currentServiceFilters(String obj, String cat, Integer dept, String q) {
Map<String, Object> filters = new LinkedHashMap<>();
if (trimToNull(obj) != null) {
filters.put("obj", obj.trim());
}
if (trimToNull(cat) != null) {
filters.put("cat", cat.trim());
}
if (dept != null) {
filters.put("dept", dept);
}
if (q != null && !q.trim().isEmpty()) {
filters.put("q", q.trim());
}
return filters;
}
/** 三组筛选项 → 模板 Facet({code|id, name, count, active, url},url 直用于 th:href) */
private Map<String, Object> buildServiceFilters(Integer siteId, PortalUrlHelper url,
String obj, String cat, Integer dept,
Map<String, Object> currentFilters) {
PortalServiceFilters filters = svcItemQuery.portalFilters(siteId);
Map<String, Object> model = new HashMap<>();
List<PortalServiceFilters.Facet> objects = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getObjects()) {
boolean active = option.getKey().equals(trimToNull(obj));
objects.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "obj", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> categories = new ArrayList<>();
for (PortalServiceFilters.Option option : filters.getCategories()) {
boolean active = option.getKey().equals(trimToNull(cat));
categories.add(PortalServiceFilters.Facet.ofKey(option.getKey(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "cat", active ? null : option.getKey()))));
}
List<PortalServiceFilters.Facet> depts = new ArrayList<>();
for (PortalServiceFilters.DeptOption option : filters.getDepts()) {
boolean active = option.getId() != null && option.getId().equals(dept);
depts.add(PortalServiceFilters.Facet.ofId(option.getId(), option.getName(),
option.getCount(), active,
url.serviceList(toggleFilter(currentFilters, "dept",
active ? null : String.valueOf(option.getId())))));
}
model.put("objects", objects);
model.put("categories", categories);
model.put("depts", depts);
return model;
}
/** 复制当前筛选并替换/移除某一维(其余维度保留,page 永不进入——选项切换即归第 1 页) */
private Map<String, Object> toggleFilter(Map<String, Object> current, String key, String value) {
Map<String, Object> next = new LinkedHashMap<>(current);
next.remove("page");
if (value == null) {
next.remove(key);
} else {
next.put(key, value);
}
return next;
}
private static String trimToNull(String value) {
if (value == null) {
return null;
}
String trimmed = value.trim();
return trimmed.isEmpty() ? null : trimmed;
}
/**
* 站点身份解析(W4):优先按门户 path(siteSnapshotByPath);
* 未命中且为单段旧链时按 code 回查(D3:已发出的院系列表链接/收藏不断)——
* 命中子站(path≠code)则 301 到嵌套形态(保留子路径 subPath 与 query)后返回 null;
* 仍未命中 404。
*/
private PortalSiteSnapshot requireSiteByPath(String sitePath, String subPath,
HttpServletRequest request,
HttpServletResponse response) throws Exception {
PortalSiteSnapshot site = portalQueryService.siteSnapshotByPath(sitePath);
if (site != null) {
return site;
}
if (sitePath != null && !sitePath.contains("/")) {
CmsSite legacy = siteRepository.findFirstByCodeOrderByIdAsc(sitePath).orElse(null);
if (legacy != null && legacy.getPath() != null && !legacy.getPath().isEmpty()
&& !legacy.getPath().equals(sitePath)) {
StringBuilder location = new StringBuilder(request.getContextPath())
.append("/portal/").append(legacy.getPath()).append('/');
if (subPath != null && !subPath.isEmpty()) {
location.append(subPath);
}
String query = request.getQueryString();
if (query != null && !query.isEmpty()) {
location.append('?').append(query);
}
response.setStatus(HttpServletResponse.SC_MOVED_PERMANENTLY);
response.setHeader("Location", location.toString());
return null;
}
}
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return null;
}
/**
* URL 生成器基准三级决策(方案 §8.6):
* ① 站点 public_base_url(绑学校域名;W4 起子站可为 D4 派生值 {主站base}/{code});
* ② 平台 cms.portal.base-origin(未绑域时的 base 域规范形态,C-6);
* ③ 当前请求派生(本地/预览形态,反代下经 ForwardedHeaderFilter 修正 scheme/port,B-2)。
* theme 随 helper 走,使资产 URL 含主题段(B-3)。
* W4:构造参语义 code→sitePath——门户地址全站统一为 /portal/{path}/**。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request) {
return buildUrl(site, request, null);
}
/**
* M0 可选模板整页回退配套(事项库 §4.2):effectiveTheme 非空时以它构造 URL 生成器——
* 资产 URL 主题段(asset())必须与视图名/themeBase 同主题,否则 default 的 HTML 配别家皮肤的 CSS。
* 三级基准决策不变(public_base_url → base-origin → 当前请求派生)。
*/
private PortalUrlHelper buildUrl(PortalSiteSnapshot site, HttpServletRequest request, String effectiveTheme) {
String theme = effectiveTheme == null ? site.getTheme() : effectiveTheme;
if (site.getPublicBaseUrl() != null) {
return new PortalUrlHelper(site.getPublicBaseUrl(), site.getPath(), theme, baseOrigin);
}
if (baseOrigin != null && !baseOrigin.trim().isEmpty()) {
return PortalUrlHelper.fromBaseOrigin(baseOrigin, site.getPath(), theme);
}
return PortalUrlHelper.fromRequest(request, site.getPath(), theme);
}
/** 全树递归查找栏目(管理端允许 3 级以上栏目,门户不得因层级深而 404,bug D-1) */
private PortalCategory findCategory(PortalSiteSnapshot site, Integer categoryId) {
for (PortalCategory root : site.getCategories()) {
PortalCategory hit = findCategoryDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalCategory findCategoryDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
return node;
}
for (PortalCategory child : node.getChildren()) {
PortalCategory hit = findCategoryDeep(child, categoryId);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
}
private PortalListData.Item prevArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) {
LocalDateTime pt = publishTime == null ? LocalDateTime.now() : publishTime;
List<CmsArticle> list = articleRepository.findPrevPublished(siteId,
ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1));
return list.isEmpty() ? null : toItem(list.get(0));
}
private PortalListData.Item nextArticle(Integer siteId, Integer articleId, LocalDateTime publishTime) {
LocalDateTime pt = publishTime == null ? LocalDateTime.MIN : publishTime;
List<CmsArticle> list = articleRepository.findNextPublished(siteId,
ArticleStatusEnum.PUBLISHED.getCode(), pt, articleId, PageRequest.of(0, 1));
return list.isEmpty() ? null : toItem(list.get(0));
}
private PortalListData emptyList(Integer page) {
PortalListData data = new PortalListData();
data.setPage(page == null ? 1 : page);
return data;
}
private String seoTitle(PortalCategory category, PortalSiteSnapshot site) {
String title = category.getSeoTitle() == null || category.getSeoTitle().isEmpty()
? category.getName() : category.getSeoTitle();
return title + " - " + site.getName();
}
}
@@ -0,0 +1,37 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.repository.CmsSiteRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.util.List;
/**
* 门户根路径(/cms/portal/):跳转首个启用的顶层校站点(院系子站不作默认入口,《院系子站方案》)。
* 容器健康探活打此路径(docker-compose healthcheck),返回 302 即视为存活。
*/
@RestController
@RequiredArgsConstructor
public class PortalIndexController {
private final CmsSiteRepository siteRepository;
@GetMapping({"/portal", "/portal/"})
public void index(HttpServletRequest request, HttpServletResponse response) throws Exception {
// B-5:显式按 id 升序,多顶层站时 302 目标确定(healthcheck 与用户首跳不漂移)
List<CmsSite> sites = siteRepository.findByStatusOrderByIdAsc(1);
if (sites.isEmpty()) {
response.sendError(HttpServletResponse.SC_NOT_FOUND, "暂无启用站点");
return;
}
CmsSite target = sites.stream()
.filter(s -> s.getParentSiteId() == null || s.getParentSiteId() == 0)
.findFirst()
.orElse(sites.get(0));
response.sendRedirect(request.getContextPath() + "/portal/" + target.getPath() + "/");
}
}
@@ -0,0 +1,828 @@
package com.yunhai.cms.controller.portal;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.model.portal.PortalBanner;
import com.yunhai.cms.model.portal.PortalCategory;
import com.yunhai.cms.model.portal.PortalListData;
import com.yunhai.cms.model.portal.PortalSideNav;
import com.yunhai.cms.model.portal.PortalSiteSnapshot;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.theme.ThemeRegistry;
import com.yunhai.cms.utils.PortalUrlHelper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Component;
import org.springframework.ui.Model;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Pattern;
/**
* 门户页面装配组件(方案 §5.5,P1 抽取重构):PortalController 与主题预览接口共用,
* 由 PortalSsrIT 全绿守护既有行为。首页数据装配(轮播时间窗/置顶/栏目区块)在此收口。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PortalPageRenderer {
/** 首页每个一级栏目取 N 条(docs/12:双栏新闻卡形态需 16 条铺满 8 行 × 2 列) */
private static final int HOME_BLOCK_SIZE = 8;
private static final int HOME_BLOCK_GRID_SIZE = 16;
/** 首页置顶区取 N 条 */
private static final int HOME_TOP_SIZE = 6;
private final CmsArticleRepository articleRepository;
private final ThemeRegistry themeRegistry;
/** docs/20 D-7:people 区块数据源 */
private final com.yunhai.cms.repository.CmsPersonRepository personRepository;
/**
* M0 可选模板整页回退(事项库详细设计 §四):目标主题缺该页模板(如 service.html/guide.html
* 这类 REQUIRED_TEMPLATES 之外的可选页)时,整页回退 default,而非仅换模板。
* 返回的 effectiveTheme 必须同时用于视图名、themeBase 与 URL 生成器(asset() 的
* 资产主题段)——只换模板不换主题 = default 的 HTML 配别家皮肤的 CSS。
* 必备四页由 ThemePackageValidator 注册期保证,不走本判定。
*/
public String resolveEffectiveTheme(PortalSiteSnapshot site, String requiredTemplate) {
String theme = site.getTheme();
if (themeRegistry.resource(theme, requiredTemplate).exists()) {
return theme;
}
log.warn("主题 {} 缺可选模板 {}(site={}),整页回退 {}", theme, requiredTemplate,
site.getCode(), ThemeRegistry.DEFAULT_THEME);
return ThemeRegistry.DEFAULT_THEME;
}
/** 首页装配 + 视图名(方案 §4.3 契约:themeParams/themeVarsCss 顶层属性在此统一挂载)。
* docs/16 主线B:home_layout 落库后按区块序列渲染(layoutBlocks);未编排布局时
* 原样回落现状装配(D2 兼容,老站一个像素都不变)。 */
public String renderHome(PortalSiteSnapshot site, PortalUrlHelper url, Model model) {
model.addAttribute("url", url);
model.addAttribute("site", site);
model.addAttribute("themeParams", site.getThemeParams());
model.addAttribute("themeVarsCss", site.getThemeVarsCss());
model.addAttribute("customCss", site.getCustomCss());
model.addAttribute("themeBase", "themes/" + site.getTheme());
model.addAttribute("navCategories", site.getCategories());
List<Map<String, Object>> banners = activeBanners(site, "home", url);
model.addAttribute("banners", banners);
List<Map<String, Object>> layoutBlocks = buildLayoutBlocks(site, site.getHomeLayout(), url);
if (layoutBlocks != null) {
// 布局模式:页面级固定槽位(banner)保留;其余段落由区块序列接管,回落区清空防模板兜底段漏出
model.addAttribute("layoutBlocks", layoutBlocks);
model.addAttribute("galleryBanners", new ArrayList<>());
model.addAttribute("topArticles", new ArrayList<>());
model.addAttribute("focusArticles", new ArrayList<>());
model.addAttribute("categoryBlocks", new ArrayList<>());
} else {
model.addAttribute("galleryBanners", activeBanners(site, "gallery", url));
model.addAttribute("topArticles", topArticles(site.getId(), HOME_TOP_SIZE));
model.addAttribute("focusArticles", focusArticles(site.getId()));
model.addAttribute("categoryBlocks", categoryBlocks(site.getId(), site.getCategories(), url));
}
model.addAttribute("pageTitle", site.getName());
model.addAttribute("seoDescription", site.getConfig() == null ? null
: site.getConfig().get("seo_description"));
model.addAttribute("canonical", url.home());
// og:image(C-7):轮播首图(已补基准)优先,退站点 Logo
Object firstBanner = banners.isEmpty() ? null : banners.get(0).get("imageUrl");
Object logo = site.getConfig() == null ? null : site.getConfig().get("logo");
model.addAttribute("ogImage", firstBanner != null ? firstBanner : url.upload(
logo == null ? null : logo.toString()));
return "themes/" + site.getTheme() + "/home";
}
// ===== 内部装配(自 PortalController 原样迁移)=====
/**
* 栏目导航装配(docs/11):栏目页/详情页共用,三条模型属性——
* categoryPath(顶级→当前路径,面包屑全路径)、navTopId(顶导航祖先高亮)、
* sideNav(模块有二级子栏目时的左侧兄弟导航,无则不挂,模板 th:if 兜底)。
* 全部基于站点快照树推导,零新增查询;搜索/首页等无栏目上下文的路由不调用。
*/
public void attachCategoryNavigation(PortalSiteSnapshot site, Integer currentCategoryId, Model model) {
if (currentCategoryId == null) {
return;
}
List<PortalCategory> path = pathFromRoot(site.getCategories(), currentCategoryId);
model.addAttribute("categoryPath", path);
if (path.isEmpty()) {
return; // 栏目已删等兜底(详情页可能挂已删栏目文章),模板按缺省形态渲染
}
PortalCategory top = path.get(0);
model.addAttribute("navTopId", top.getId());
if (!top.getChildren().isEmpty()) {
PortalSideNav nav = new PortalSideNav();
nav.setTop(top);
nav.setItems(top.getChildren());
nav.setActiveId(resolveActiveId(path, currentCategoryId));
model.addAttribute("sideNav", nav);
}
}
/**
* 侧栏高亮位:当前为二级=当前栏目;一级直点=null(侧栏头即模块名,无高亮项);
* 三级及更深=所在二级分支(决策点②:固定列二级层,高亮当前所属分支)。
*/
private Integer resolveActiveId(List<PortalCategory> path, Integer currentCategoryId) {
if (path.size() == 2) {
return currentCategoryId;
}
return path.size() > 2 ? path.get(1).getId() : null;
}
/** 自栏目树求 根→目标 路径(含两端);目标不存在返回空表 */
private List<PortalCategory> pathFromRoot(List<PortalCategory> roots, Integer categoryId) {
for (PortalCategory root : roots) {
List<PortalCategory> hit = pathFromRootDeep(root, categoryId);
if (hit != null) {
return hit;
}
}
return new ArrayList<>();
}
private List<PortalCategory> pathFromRootDeep(PortalCategory node, Integer categoryId) {
if (node.getId().equals(categoryId)) {
List<PortalCategory> path = new ArrayList<>();
path.add(node);
return path;
}
for (PortalCategory child : node.getChildren()) {
List<PortalCategory> sub = pathFromRootDeep(child, categoryId);
if (sub != null) {
sub.add(0, node);
return sub;
}
}
return null;
}
/** 启用中且在时间窗内的指定位轮播,并解析为完整跳转 URL */
private List<Map<String, Object>> activeBanners(PortalSiteSnapshot site, String position,
PortalUrlHelper url) {
List<Map<String, Object>> result = new ArrayList<>();
LocalDateTime now = LocalDateTime.now();
for (PortalBanner b : site.getBanners()) {
if (!position.equals(b.getPosition())) {
continue;
}
if (b.getStartTime() != null && now.isBefore(b.getStartTime())) {
continue; // 定时上线未到
}
if (b.getEndTime() != null && !now.isBefore(b.getEndTime())) {
continue; // 已过下线时间
}
Map<String, Object> item = new HashMap<>();
item.put("id", b.getId());
item.put("title", b.getTitle());
// 上传图渲染期补基准(B-1):入库相对地址 → 当前基准完整 URL
item.put("imageUrl", url.upload(b.getImageUrl()));
item.put("linkUrl", resolveBannerLink(b, url));
result.add(item);
}
return result;
}
private String resolveBannerLink(PortalBanner b, PortalUrlHelper url) {
LinkTypeEnum type = LinkTypeEnum.byCode(b.getLinkType());
if (type == LinkTypeEnum.ARTICLE) {
return url.article(b.getLinkValue());
}
if (type == LinkTypeEnum.CATEGORY) {
return url.category(b.getLinkValue());
}
return b.getLinkValue();
}
/** 首页置顶文章区:数据库精确取 N 条(置顶优先 + 发布时间倒序) */
private List<PortalListData.Item> topArticles(Integer siteId, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/**
* 首页"图片新闻"卡(视觉精修 D2-1):有封面的已发布文章取 3 条(发布时间倒序)。
* 无封面文章不进此卡(无封面边界由列表页文字行呈现,不造占位图)。
*/
private List<PortalListData.Item> focusArticles(Integer siteId) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository.findBySiteIdAndStatus(
siteId, com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, 30, Sort.by(Sort.Order.desc("publishTime"))));
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
if (a.getCoverImage() == null || a.getCoverImage().trim().isEmpty()) {
continue;
}
items.add(toItem(a));
if (items.size() >= 3) {
break;
}
}
return items;
}
/**
* 首页一级栏目区块:每栏目精确取 top N(列表型栏目才出区块)。
* D1-2:区块聚合列表型子孙栏目文章(文章通常挂叶子栏目,父栏目区块必须显示汇总)。
* docs/12:区块带出栏目 homeStyle,双栏新闻卡(news_grid)取 16 条铺满双栏,其余取 8 条。
*/
private List<Map<String, Object>> categoryBlocks(Integer siteId, List<PortalCategory> categories,
PortalUrlHelper url) {
List<Map<String, Object>> blocks = new ArrayList<>();
for (PortalCategory category : categories) {
if (category.getType() == null || category.getType() != CategoryTypeEnum.LIST.getCode()) {
continue;
}
int blockSize = "news_grid".equals(category.getHomeStyle())
? HOME_BLOCK_GRID_SIZE : HOME_BLOCK_SIZE;
List<Integer> categoryIds = new ArrayList<>();
collectListDescendantIds(category, categoryIds);
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, blockSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
Map<String, Object> block = new HashMap<>();
block.put("category", category);
block.put("homeStyle", category.getHomeStyle());
block.put("items", items);
block.put("moreUrl", url.category(category.getId()));
blocks.add(block);
}
return blocks;
}
/** 快照栏目树深度优先收集列表型子孙 id(含自身;单页/外链子孙不入列也不下钻) */
private void collectListDescendantIds(PortalCategory category, List<Integer> ids) {
ids.add(category.getId());
for (PortalCategory child : category.getChildren()) {
if (child.getType() != null && child.getType() == CategoryTypeEnum.LIST.getCode()) {
collectListDescendantIds(child, ids);
}
}
}
// ===== 首页布局装配(docs/16 主线B §4.4)=====
/** 布局区块条数上限与缺省(与编辑器/服务端校验同约束) */
private static final int LAYOUT_SIZE_MAX = 20;
private static final int LAYOUT_LIST_SIZE_DEFAULT = 8;
private static final int LAYOUT_TICKER_SIZE_DEFAULT = 6;
/** 区块内手填链接:http(s) 绝对地址或站内根相对路径(与 SiteService 保存期校验同契约,拒绝伪协议) */
private static final Pattern LAYOUT_LINK_URL = Pattern.compile("^(https?://\\S+|/.+)$");
/**
* home_layout → 模板区块序列。null = 无布局/结构非法(回落现状装配,D2)。
* 每块输出与 categoryBlocks 同构的 Map(category.name/homeStyle 供主题 fragment 兼容消费),
* 另带 type/style/title/subtitle/background/source 布局属性;visible=false 的块保序跳过。
*/
private List<Map<String, Object>> buildLayoutBlocks(PortalSiteSnapshot site, Map<String, Object> homeLayout,
PortalUrlHelper url) {
if (homeLayout == null) {
return null;
}
Object raw = homeLayout.get("blocks");
if (!(raw instanceof List)) {
return null; // 容错:blocks 非数组视为无布局
}
Set<Integer> treeIds = new java.util.HashSet<>();
collectTreeIds(site.getCategories(), treeIds);
List<Map<String, Object>> result = new ArrayList<>();
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<String, Object> spec = (Map<String, Object>) o;
if (Boolean.FALSE.equals(spec.get("visible"))) {
continue; // 编辑器「隐藏」:保序下线
}
Map<String, Object> block = buildLayoutBlock(site, spec, treeIds, url);
if (block != null) {
result.add(block);
}
}
return result;
}
private void collectTreeIds(List<PortalCategory> categories, Set<Integer> ids) {
for (PortalCategory c : categories) {
ids.add(c.getId());
collectTreeIds(c.getChildren(), ids);
}
}
private Map<String, Object> buildLayoutBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
String type = spec.get("type") == null ? "" : String.valueOf(spec.get("type"));
switch (type) {
case "article_list":
return buildArticleListBlock(site, spec, treeIds, url);
case "ticker":
return buildTickerBlock(site, spec, treeIds);
case "gallery":
return buildGalleryBlock(site, spec, url);
case "links":
return buildLinksBlock(site, spec);
case "quick_links":
return buildQuickLinksBlock(spec);
case "notice_bar":
return buildNoticeBarBlock(spec);
// ===== docs/20 D-1~D-9 新组件(空数据返回 null = 整块不渲染,R-44)=====
case "stats_band":
return buildStatsBandBlock(spec);
case "featured_topics":
return buildFeaturedTopicsBlock(site, spec, url);
case "video_tour":
return buildVideoTourBlock(spec, url);
case "events":
return buildEventsBlock(site, spec, url);
case "people":
return buildPeopleBlock(site, spec, treeIds);
case "badges":
return buildBadgesBlock(spec);
default:
return null; // 白名单外的脏数据跳过,不打挂渲染
}
}
/** 区块公共头:type/title,并合成为 category.name 供主题 fragment 复用(block.category.name 取标题)。
* F2(docs/20):透传区块 id(编辑器实时预览 postMessage 重排的 DOM 锚点 data-plb-id)。
* D-4(docs/20):subtitle 全类型通用(中英双语栏目标题——主题 titleStyle 参数控制展示开关)。 */
private Map<String, Object> baseBlock(Map<String, Object> spec, String type, String fallbackTitle) {
Map<String, Object> block = new HashMap<>();
block.put("type", type);
if (spec.get("id") != null) {
block.put("id", String.valueOf(spec.get("id")));
}
String title = spec.get("title") == null ? "" : String.valueOf(spec.get("title")).trim();
if (title.isEmpty()) {
title = fallbackTitle;
}
block.put("title", title);
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
Map<String, Object> category = new HashMap<>();
category.put("name", title);
block.put("category", category);
return block;
}
/**
* 栏目文章区:categoryIds 逐个解析到快照树节点(不在树内的 id 跳过,防跨站/已删栏目注入),
* 聚合列表型子孙后 IN 查询取 size 条(复用 categoryBlocks 同款查询与子孙收集)。
*/
private Map<String, Object> buildArticleListBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds, PortalUrlHelper url) {
List<Integer> categoryIds = new ArrayList<>();
PortalCategory first = null;
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node == null) {
continue;
}
collectListDescendantIds(node, categoryIds);
if (first == null) {
first = node;
}
}
int size = clampSize(spec.get("size"), LAYOUT_LIST_SIZE_DEFAULT);
List<PortalListData.Item> items = categoryIds.isEmpty()
? new ArrayList<>()
: articlesIn(site.getId(), categoryIds, size);
String style = spec.get("style") == null ? "card" : String.valueOf(spec.get("style"));
Map<String, Object> block = baseBlock(spec, "article_list",
first != null ? first.getName() : "栏目文章");
block.put("style", style);
block.put("homeStyle", style); // 主题 fragment 兼容:cg-newshot 等按 homeStyle 分支
block.put("items", items);
block.put("size", size);
block.put("moreUrl", first != null ? url.category(first.getId()) : url.home());
// 背景大图(§4.5):区块未设 background 时以旧键 news_bg_image 为默认值(存量配置无损生效)
boolean background = spec.get("background") != null
? Boolean.parseBoolean(String.valueOf(spec.get("background")))
: hasText(configValue(site, "news_bg_image"));
block.put("background", background);
return block;
}
/** 滚动速递:source=top 走全站置顶;source=category 走栏目 IN 查询(树外栏目跳过) */
private Map<String, Object> buildTickerBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
String source = spec.get("source") == null ? "top" : String.valueOf(spec.get("source"));
int size = clampSize(spec.get("size"), LAYOUT_TICKER_SIZE_DEFAULT);
List<PortalListData.Item> items;
if ("category".equals(source)) {
List<Integer> categoryIds = new ArrayList<>();
for (Integer id : intList(spec.get("categoryIds"))) {
if (!treeIds.contains(id)) {
continue;
}
PortalCategory node = findInTree(site.getCategories(), id);
if (node != null) {
collectListDescendantIds(node, categoryIds);
}
}
items = categoryIds.isEmpty() ? new ArrayList<>() : articlesIn(site.getId(), categoryIds, size);
} else {
items = topArticles(site.getId(), size);
}
Map<String, Object> block = baseBlock(spec, "ticker", "要闻速递");
block.put("source", source);
block.put("items", items);
block.put("size", size);
return block;
}
/**
* 图片画廊:数据通道不变(轮播位 gallery,轮播管理维护)。
* 标题/副题(§4.5):区块未设时以旧键 gallery_title/gallery_subtitle 为默认值;
* size 未设 = 全部展示(历史站点画廊数量不定,不加隐式上限)。
*/
private Map<String, Object> buildGalleryBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
Map<String, Object> config = site.getConfig() == null ? new HashMap<>() : site.getConfig();
Map<String, Object> block = baseBlock(spec, "gallery", str(config.get("gallery_title"), "线上展厅"));
String subtitle = spec.get("subtitle") == null ? "" : String.valueOf(spec.get("subtitle")).trim();
if (subtitle.isEmpty()) {
subtitle = str(config.get("gallery_subtitle"), "Online Exhibition Hall");
}
if (!subtitle.isEmpty()) {
block.put("subtitle", subtitle);
}
// D-8(docs/20):campus_wall 布局开关(grid 规整网格 / masonry 瀑布流)
block.put("layout", "masonry".equals(str(spec.get("layout"), "")) ? "masonry" : "grid");
block.put("items", activeBanners(site, "gallery", url));
return block;
}
/** 链接组:友情链接分组(group 未设 = 全部分组;首页此前未消费该数据,现有通道直出) */
private Map<String, Object> buildLinksBlock(PortalSiteSnapshot site, Map<String, Object> spec) {
String group = spec.get("group") == null ? "" : String.valueOf(spec.get("group")).trim();
List<com.yunhai.cms.model.portal.PortalLinkGroup> groups = new ArrayList<>();
for (com.yunhai.cms.model.portal.PortalLinkGroup g : site.getLinkGroups()) {
if (group.isEmpty() || group.equals(g.getGroupName())) {
groups.add(g);
}
}
Map<String, Object> block = baseBlock(spec, "links", "友情链接");
block.put("group", group);
block.put("linkGroups", groups);
return block;
}
/**
* 快捷入口宫格(v2 手册 §2.8 类型 A;docs/20 D-2 升级图标宫格):数据是区块内联数组,不读任何外部表。
* 保存期已校验 url/icon 白名单,装配期对脏数据(直改库/历史配置)再容错一次——不合法项静默丢弃,不打挂渲染。
*/
private Map<String, Object> buildQuickLinksBlock(Map<String, Object> spec) {
List<Map<String, Object>> links = new ArrayList<>();
Object raw = spec.get("items");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String name = str(item.get("name"), "").trim();
String linkUrl = str(item.get("url"), "").trim();
if (name.isEmpty() || !LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
continue;
}
Map<String, Object> link = new HashMap<>();
link.put("name", name);
link.put("url", linkUrl);
// D-2:可选图标标识符(未选时骨架渲染首字徽章兜底)
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
link.put("icon", icon);
}
links.add(link);
}
}
Map<String, Object> block = baseBlock(spec, "quick_links", "快捷通道");
block.put("quickLinks", links);
return block;
}
/** 公告条(v2 手册 §2.8 类型 B):站点单条手填通告;level 白名单外归一 info,url 不合法则退化为纯文本 */
private Map<String, Object> buildNoticeBarBlock(Map<String, Object> spec) {
String linkUrl = str(spec.get("url"), "").trim();
if (!LAYOUT_LINK_URL.matcher(linkUrl).matches()) {
linkUrl = "";
}
Map<String, Object> block = baseBlock(spec, "notice_bar", "公告");
block.put("noticeText", str(spec.get("text"), "").trim());
block.put("noticeUrl", linkUrl);
block.put("noticeLevel", "warn".equals(str(spec.get("level"), "").trim()) ? "warn" : "info");
return block;
}
// ===== docs/20 D 新组件装配(空数据返回 null → buildLayoutBlocks 跳过 = 整块不渲染,R-44)=====
/** stats_band(D-1 数字计数带):stats 内联数组透传(骨架共享 count-up 动画);空/无有效项 → 不渲染 */
private Map<String, Object> buildStatsBandBlock(Map<String, Object> spec) {
List<Map<String, Object>> stats = new ArrayList<>();
Object raw = spec.get("stats");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> item = (Map<?, ?>) o;
String label = str(item.get("label"), "").trim();
String value = str(item.get("value"), "").trim();
if (label.isEmpty() || value.isEmpty()) {
continue;
}
Map<String, Object> stat = new HashMap<>();
stat.put("label", label);
stat.put("value", value);
String unit = str(item.get("unit"), "").trim();
if (!unit.isEmpty()) {
stat.put("unit", unit);
}
String icon = str(item.get("icon"), "").trim();
if (!icon.isEmpty() && icon.matches("^[a-z0-9-]{1,30}$")) {
stat.put("icon", icon);
}
stats.add(stat);
}
}
if (stats.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "stats_band", "数说学校");
block.put("stats", stats);
return block;
}
/** featured_topics(D-3 专题大卡):数据 = banner 表 topic 展示位(描述/CTA 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildFeaturedTopicsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if (!"topic".equals(b.getPosition()) || !isActiveNow(b)) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("imageUrl", url.upload(b.getImageUrl()));
item.put("linkUrl", resolveBannerLink(b, url));
if (hasText(b.getDescription())) {
item.put("description", b.getDescription());
}
if (hasText(b.getCtaText())) {
item.put("ctaText", b.getCtaText());
}
if (hasText(b.getCtaUrl())) {
item.put("ctaUrl", b.getCtaUrl());
}
items.add(item);
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "featured_topics", "专题推荐");
block.put("items", items);
return block;
}
/** video_tour(D-5 视频/VR 校园):cover 资产补基准 + url + title;脏数据(缺 cover/url)→ 不渲染 */
private Map<String, Object> buildVideoTourBlock(Map<String, Object> spec, PortalUrlHelper url) {
String cover = str(spec.get("cover"), "").trim();
String videoUrl = str(spec.get("url"), "").trim();
if (!ASSET_URL_PATTERN.matcher(cover).matches() || !LAYOUT_LINK_URL.matcher(videoUrl).matches()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "video_tour", "云端校园");
block.put("cover", url.upload(cover));
block.put("videoUrl", videoUrl);
return block;
}
/** events(D-6 学术日历/活动):数据 = banner 表 event 展示位(event_date/location 扩展列);无在展数据 → 不渲染 */
private Map<String, Object> buildEventsBlock(PortalSiteSnapshot site, Map<String, Object> spec,
PortalUrlHelper url) {
List<PortalBanner> events = new ArrayList<>();
for (PortalBanner b : site.getBanners()) {
if ("event".equals(b.getPosition()) && isActiveNow(b)) {
events.add(b);
}
}
// 活动日历语义:按活动日期升序(无日期的排后按 sortNo),最多 6 条
events.sort(java.util.Comparator
.comparing((PortalBanner b) -> b.getEventDate() == null ? java.time.LocalDateTime.MAX : b.getEventDate())
.thenComparing(PortalBanner::getSortNo, java.util.Comparator.nullsLast(Integer::compareTo)));
if (events.size() > 6) {
events = events.subList(0, 6);
}
if (events.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (PortalBanner b : events) {
Map<String, Object> item = new HashMap<>();
item.put("title", b.getTitle());
item.put("linkUrl", resolveBannerLink(b, url));
if (b.getEventDate() != null) {
item.put("eventDate", b.getEventDate());
}
if (hasText(b.getLocation())) {
item.put("location", b.getLocation());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "events", "学术活动");
block.put("items", items);
return block;
}
/** people(D-7 名师风采):cms_person 启用数据(categoryId 可选过滤,须在本站栏目树内);无数据 → 不渲染 */
private Map<String, Object> buildPeopleBlock(PortalSiteSnapshot site, Map<String, Object> spec,
Set<Integer> treeIds) {
Integer categoryId = null;
Object rawCategory = spec.get("categoryId");
if (rawCategory instanceof Number && ((Number) rawCategory).intValue() > 0
&& treeIds.contains(((Number) rawCategory).intValue())) {
categoryId = ((Number) rawCategory).intValue();
}
List<com.yunhai.cms.entity.CmsPerson> people = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p
: personRepository.findBySiteIdAndStatusOrderBySortNoAscIdAsc(site.getId(), 1)) {
if (categoryId != null && !categoryId.equals(p.getCategoryId())) {
continue;
}
people.add(p);
if (people.size() >= 12) {
break;
}
}
if (people.isEmpty()) {
return null;
}
List<Map<String, Object>> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsPerson p : people) {
Map<String, Object> item = new HashMap<>();
item.put("name", p.getName());
if (hasText(p.getTitle())) {
item.put("title", p.getTitle());
}
if (hasText(p.getBio())) {
item.put("bio", p.getBio());
}
if (hasText(p.getAvatar())) {
item.put("avatar", p.getAvatar());
}
items.add(item);
}
Map<String, Object> block = baseBlock(spec, "people", "名师风采");
block.put("items", items);
return block;
}
/** badges(D-9 排名/学科评估徽章带):badgeItems 内联数组透传;空 → 不渲染 */
private Map<String, Object> buildBadgesBlock(Map<String, Object> spec) {
List<Map<String, Object>> items = new ArrayList<>();
Object raw = spec.get("badgeItems");
if (raw instanceof List) {
for (Object o : (List<?>) raw) {
if (!(o instanceof Map)) {
continue;
}
Map<?, ?> m = (Map<?, ?>) o;
String label = str(m.get("label"), "").trim();
String image = str(m.get("image"), "").trim();
if (label.isEmpty() || !ASSET_URL_PATTERN.matcher(image).matches()) {
continue;
}
Map<String, Object> item = new HashMap<>();
item.put("label", label);
item.put("image", image);
String desc = str(m.get("desc"), "").trim();
if (!desc.isEmpty()) {
item.put("desc", desc);
}
items.add(item);
}
}
if (items.isEmpty()) {
return null;
}
Map<String, Object> block = baseBlock(spec, "badges", "实力声誉");
block.put("items", items);
return block;
}
/** banner 时间窗过滤(activeBanners 同语义,供 topic/event 位复用;快照内 banner 已过滤启用态) */
private boolean isActiveNow(PortalBanner b) {
java.time.LocalDateTime now = java.time.LocalDateTime.now();
return (b.getStartTime() == null || !now.isBefore(b.getStartTime()))
&& (b.getEndTime() == null || now.isBefore(b.getEndTime()));
}
// ===== 布局装配工具 =====
/** 区块内图片资产 URL 形态(与 SiteService ASSET_PATTERN 同语义) */
private static final Pattern ASSET_URL_PATTERN = Pattern.compile("^(/.+|https?://\\S+)$");
private static String str(Object v, String fallback) {
return v == null ? fallback : String.valueOf(v);
}
private static boolean hasText(String v) {
return v != null && !v.trim().isEmpty();
}
private static String configValue(PortalSiteSnapshot site, String key) {
Object v = site.getConfig() == null ? null : site.getConfig().get(key);
return v == null ? null : String.valueOf(v);
}
private static List<Integer> intList(Object v) {
List<Integer> ids = new ArrayList<>();
if (v instanceof List) {
for (Object o : (List<?>) v) {
if (o instanceof Number && ((Number) o).intValue() > 0) {
ids.add(((Number) o).intValue());
}
}
}
return ids;
}
private static int clampSize(Object v, int fallback) {
if (!(v instanceof Number)) {
return fallback;
}
int n = ((Number) v).intValue();
return Math.max(1, Math.min(LAYOUT_SIZE_MAX, n));
}
private List<PortalListData.Item> articlesIn(Integer siteId, List<Integer> categoryIds, int size) {
List<com.yunhai.cms.entity.CmsArticle> articles = articleRepository
.findBySiteIdAndCategoryIdInAndStatus(siteId, categoryIds,
com.yunhai.cms.enums.ArticleStatusEnum.PUBLISHED.getCode(),
PageRequest.of(0, size,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("publishTime"))))
.getContent();
List<PortalListData.Item> items = new ArrayList<>();
for (com.yunhai.cms.entity.CmsArticle a : articles) {
items.add(toItem(a));
}
return items;
}
/** 快照树递归查栏目(布局数据源解析用;查不到返回 null) */
private PortalCategory findInTree(List<PortalCategory> categories, Integer id) {
for (PortalCategory c : categories) {
if (c.getId().equals(id)) {
return c;
}
PortalCategory hit = findInTree(c.getChildren(), id);
if (hit != null) {
return hit;
}
}
return null;
}
private PortalListData.Item toItem(com.yunhai.cms.entity.CmsArticle a) {
PortalListData.Item item = new PortalListData.Item();
item.setId(a.getId());
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setSource(a.getSource());
item.setPublishTime(a.getPublishTime());
item.setTopmost(a.getTopmost());
return item;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.cms.controller.portal;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.web.bind.MissingServletRequestParameterException;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
import javax.servlet.http.HttpServletResponse;
/**
* 门户 HTML 路由异常落地为错误页(体检 H-1/H-2 根因修复)。
*
* <p>门户控制器抛异常原本落到平台 JSON 兜底(匿名访客看到裸 {"code":6,...})。
* 本 advice 将参数类异常转 sendError(400)、其余转 sendError(500),
* 经 Boot 错误页机制渲染 templates/error/ 下的皮肤化错误页;
* 响应已提交(模板渲染中途失败)时只记日志不再改写响应。
*/
@Slf4j
@ControllerAdvice(assignableTypes = {PortalController.class, PortalIndexController.class,
PortalAssetController.class})
@Order(Ordered.HIGHEST_PRECEDENCE)
public class PortalWebExceptionHandler {
@ExceptionHandler({MethodArgumentTypeMismatchException.class,
MissingServletRequestParameterException.class})
public void badRequest(Exception e, HttpServletResponse response) throws Exception {
if (response.isCommitted()) {
log.warn("门户参数异常但响应已提交: {}", e.getMessage());
return;
}
log.warn("门户参数异常: {}", e.getMessage());
response.sendError(HttpServletResponse.SC_BAD_REQUEST);
}
@ExceptionHandler(Exception.class)
public void serverError(Exception e, HttpServletResponse response) throws Exception {
if (response.isCommitted()) {
log.warn("门户渲染异常且响应已提交: {}", e.getMessage());
return;
}
log.error("门户渲染异常", e);
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR);
}
}
@@ -0,0 +1,43 @@
package com.yunhai.cms.entity;
import com.yunhai.cms.common.CmsAuditListener;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.EntityListeners;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.MappedSuperclass;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 所有实体的公共基类:自增主键 + 创建人/时间 + 更新人/时间(由 CmsAuditListener 自动填充)。
* 操作日志独立表为演进项(方案 §2),MVP 靠本审计四字段追溯。
*/
@MappedSuperclass
@Getter
@Setter
@EntityListeners(CmsAuditListener.class)
public abstract class BaseEntity implements Serializable {
private static final long serialVersionUID = 1L;
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Integer id;
@Column(name = "create_by", updatable = false)
private String createBy;
@Column(name = "create_time", updatable = false)
private LocalDateTime createTime;
@Column(name = "update_by")
private String updateBy;
@Column(name = "update_time")
private LocalDateTime updateTime;
}
@@ -0,0 +1,84 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* CMS 文章(方案 §5.1 表3)。
* 审核状态机为演进项(触发后加 audit_status 列);ext_json 保留键:
* {@code wechat}(推送回执,本期不写)、{@code albums}(图集演进)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_article", indexes = {
@Index(name = "idx_art_list", columnList = "site_id, category_id, status, publish_time"),
@Index(name = "idx_art_site_top", columnList = "site_id, status, topmost")})
public class CmsArticle extends BaseEntity {
/** 所属站点(隔离/查询维度;文章不冗余 sch_id,权限经 site 一跳映射) */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 所属栏目(逻辑外键 → cms_category.id) */
@Column(name = "category_id", nullable = false)
private Integer categoryId;
/** 标题 */
@Column(name = "title", nullable = false, length = 200)
private String title;
/** 摘要(列表/SEO 用,空=截取正文) */
@Column(name = "summary", length = 500)
private String summary;
/** 正文 HTML(wangEditor 产出,保存时 jsoup 白名单清洗,方案 §8.5) */
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/** 封面图 URL */
@Column(name = "cover_image", length = 500)
private String coverImage;
/** 作者账号(发布时从 BoaUser 填充) */
@Column(name = "author_account", length = 64)
private String authorAccount;
/** 作者姓名(冗余展示) */
@Column(name = "author_name", length = 64)
private String authorName;
/** 来源(转载出处) */
@Column(name = "source", length = 128)
private String source;
/** 状态:0=草稿 1=已发布 2=已下架(见 ArticleStatusEnum) */
@Column(name = "status", nullable = false)
private Integer status = 0;
/** 置顶:0/1 */
@Column(name = "topmost", nullable = false)
private Integer topmost = 0;
/** 发布时间(空=保存即发布;未来=定时发布) */
@Column(name = "publish_time")
private LocalDateTime publishTime;
/** 下架时间(可选,@Scheduled 每分钟扫描到期记录) */
@Column(name = "offline_time")
private LocalDateTime offlineTime;
/** 浏览量(MVP 异步累加,量大后改缓存聚合) */
@Column(name = "view_count", nullable = false)
private Integer viewCount = 0;
/** 扩展字段 JSON(wechat/albums 预留,免 DDL 演进) */
@Column(name = "ext_json", columnDefinition = "JSON")
private String extJson;
}
@@ -0,0 +1,61 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* CMS 附件(正文图片 + 文档附件 + 轮播图/友链 Logo 复用同一上传链路,方案 §7.3)。
* 引用模型(B2):articleId 仅服务文章编辑器回填链路;非文章场景(轮播/友链/站点配置)
* 用 refType/refId 标记。未使用 = 无文章引用且无场景引用(articleId 与 refType 均空);
* 文章删除仅 article_id 置空,场景引用由各保存点按 webUrl 反查回写/解引用。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_attachment", indexes = {
@Index(name = "idx_att_article", columnList = "article_id"),
@Index(name = "idx_att_site", columnList = "site_id"),
@Index(name = "idx_att_ref", columnList = "ref_type, ref_id")})
public class CmsAttachment extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 关联文章(编辑器图片保存时回填) */
@Column(name = "article_id")
private Integer articleId;
/** 场景引用类型(B2):banner=轮播 / link=友链 / site=站点配置;空=无场景引用 */
@Column(name = "ref_type", length = 16)
private String refType;
/** 场景引用对象 ID(轮播/友链/站点各自业务主键) */
@Column(name = "ref_id")
private Integer refId;
/** 原始文件名 */
@Column(name = "file_name", nullable = false, length = 255)
private String fileName;
/** 存储路径(相对 FILE_PATH) */
@Column(name = "file_path", nullable = false, length = 500)
private String filePath;
/** Web 访问 URL */
@Column(name = "web_url", nullable = false, length = 500)
private String webUrl;
/** 字节数 */
@Column(name = "file_size")
private Long fileSize;
/** MIME 类型 */
@Column(name = "mime_type", length = 128)
private String mimeType;
}
@@ -0,0 +1,85 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import java.time.LocalDateTime;
/**
* CMS 轮播/推荐位(首页 banner、图片新闻位等,方案 §5.1 表5)。
* 定时上下线靠门户渲染时过滤 start/end(无需调度任务改状态)。
* 广告投放统计不做(方案 §2)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_banner", indexes = @Index(name = "idx_banner", columnList = "site_id, position, status, sort_no"))
public class CmsBanner extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 展示位标识(home=首页轮播;皮肤包约定其他位) */
@Column(name = "position", nullable = false, length = 32)
private String position = "home";
/** 标题(alt 文案/无图兜底) */
@Column(name = "title", nullable = false, length = 200)
private String title;
/** 图片 URL(上传至 FILE_PATH) */
@Column(name = "image_url", nullable = false, length = 500)
private String imageUrl;
/** 跳转类型:1=文章 2=栏目 3=外链(见 LinkTypeEnum) */
@Column(name = "link_type", nullable = false)
private Integer linkType = 1;
/** 跳转目标(文章ID/栏目ID/URL,按 link_type 解释) */
@Column(name = "link_value", length = 500)
private String linkValue;
/** 同级排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 上线时间(空=立即;未来=定时上线) */
@Column(name = "start_time")
private LocalDateTime startTime;
/** 下线时间(可选,门户渲染时过滤) */
@Column(name = "end_time")
private LocalDateTime endTime;
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
// ===== docs/20 D-3/D-6:banner 表扩展(topic=专题大卡位 / event=学术活动位)=====
// 仅对应展示位消费;home/gallery 位忽略。可空列,ddl-auto=update 自动补列(SQL 脚本另附 resources/sql)。
/** 专题大卡描述文案(position=topic) */
@Column(name = "description", length = 500)
private String description;
/** 专题大卡 CTA 按钮文字(position=topic,如「进入专题」) */
@Column(name = "cta_text", length = 100)
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Column(name = "cta_url", length = 500)
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
@Column(name = "event_date")
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Column(name = "location", length = 200)
private String location;
}
@@ -0,0 +1,74 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* CMS 栏目(站点内树形,方案 §5.1 表2)。
* 旧 CMS3.0 的"频道"层收敛为本表 type(列表/单页/外链),覆盖原频道全部用法。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_category", indexes = @Index(name = "idx_cat_tree", columnList = "site_id, parent_id, sort_no"))
public class CmsCategory extends BaseEntity {
/** 所属站点(逻辑外键 → cms_site.id) */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 父栏目ID(0=根) */
@Column(name = "parent_id", nullable = false)
private Integer parentId = 0;
/** 栏目名称 */
@Column(name = "name", nullable = false, length = 64)
private String name;
/** 栏目类型:1=文章列表 2=单页 3=外链(见 CategoryTypeEnum) */
@Column(name = "type", nullable = false)
private Integer type = 1;
/** 外链地址(type=3 必填) */
@Column(name = "link_url", length = 500)
private String linkUrl;
/** 栏目模板覆盖(空=按 type 取默认,预留能力,方案 §8.2) */
@Column(name = "template", length = 64)
private String template;
/**
* 首页展示形态(docs/11,空=主题默认卡):news_grid=双栏新闻卡、media_focus=来源标签列表。
* 仅一级列表型栏目在首页生效;旧主题不消费该字段(仅条数外溢 16)。
*/
@Column(name = "home_style", length = 32)
private String homeStyle;
/**
* 单页栏目内容(type=2 时门户直接渲染;经 jsoup 白名单清洗入库)。
* 注:方案 §8.1 要求单页直接渲染 content,但 §5.1 DDL 漏列,此处补齐(ddl-auto=update 自动加列)。
*/
@Column(name = "content", columnDefinition = "LONGTEXT")
private String content;
/** 同级排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** SEO 标题(空=用栏目名) */
@Column(name = "seo_title", length = 200)
private String seoTitle;
/** SEO 描述 */
@Column(name = "seo_description", length = 500)
private String seoDescription;
/** 状态:0=隐藏 1=显示 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -0,0 +1,48 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* CMS 友情链接(页脚/首页分组成员,方案 §5.1 表6)。
* 分组即旧 linkCategory:上级部门/兄弟院校/合作单位…,group_name 字符串直接分组。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_link", indexes = @Index(name = "idx_link", columnList = "site_id, group_name, sort_no"))
public class CmsLink extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 分组(上级部门/兄弟院校/合作单位…) */
@Column(name = "group_name", nullable = false, length = 64)
private String groupName = "default";
/** 链接名称 */
@Column(name = "name", nullable = false, length = 128)
private String name;
/** Logo URL(可选,文字链可空) */
@Column(name = "logo_url", length = 500)
private String logoUrl;
/** 链接地址 */
@Column(name = "link_url", nullable = false, length = 500)
private String linkUrl;
/** 组内排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 状态:0=隐藏 1=显示 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -0,0 +1,56 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
/**
* 名师风采人物(docs/20 D-7:首页 people 区块数据源,新表)。
* 人物按站点隔离、可挂栏目分组(category_id 可空 = 未分组);头像走附件上传通道。
* DDL:hibernate ddl-auto=update 自动建表;DBA 审阅脚本见 resources/sql/cms_person.sql(不自动执行)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_person", indexes = {
@Index(name = "idx_person_site", columnList = "site_id, status, sort_no"),
@Index(name = "idx_person_category", columnList = "category_id")
})
public class CmsPerson extends BaseEntity {
/** 所属站点 */
@Column(name = "site_id", nullable = false)
private Integer siteId;
/** 姓名 */
@Column(name = "name", nullable = false, length = 100)
private String name;
/** 头衔/职称(如 教授、长江学者) */
@Column(name = "title", length = 100)
private String title;
/** 一句话简介 */
@Column(name = "bio", length = 500)
private String bio;
/** 头像 URL(附件上传,/cms/upload/** 或 http(s)) */
@Column(name = "avatar", length = 500)
private String avatar;
/** 所属栏目外键(可空 = 未分组;people 区块按此过滤) */
@Column(name = "category_id")
private Integer categoryId;
/** 同级排序(升序) */
@Column(name = "sort_no", nullable = false)
private Integer sortNo = 0;
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -0,0 +1,72 @@
package com.yunhai.cms.entity;
import lombok.Getter;
import lombok.Setter;
import javax.persistence.Column;
import javax.persistence.Entity;
import javax.persistence.Index;
import javax.persistence.Table;
import javax.persistence.UniqueConstraint;
/**
* CMS 站点(多租户根,方案 §5.1 表1)。
*
* <p>站点层级(《院系子站方案》):{@code parent_site_id}=0 为顶层校站点(一校一主站),
* &gt;0 为该院系子站(挂主站 id,层级固定两层)。
*
* <p>config_json 保留键(方案 §8.6):{@code domain}(学校绑定域名台账)、
* {@code public_base_url}(对外基地址,默认派生自 base 域,绑学校域名后覆盖)、
* {@code colleges_label}(门户"专业院系"下拉文案,默认"专业院系")、
* {@code wechat}(公众号配置节点,本期不写,见微信平台对接方案)。
*/
@Getter
@Setter
@Entity
@Table(name = "cms_site",
uniqueConstraints = {
@UniqueConstraint(name = "uk_site_path", columnNames = "path"),
@UniqueConstraint(name = "uk_site_parent_code", columnNames = {"parent_site_id", "code"})
},
indexes = @Index(name = "idx_site_sch", columnList = "sch_id"))
public class CmsSite extends BaseEntity {
/** 站点编码(URL 末段;W4 起不再全局唯一——不同学校可重用同一编码) */
@Column(name = "code", nullable = false, length = 64)
private String code;
/**
* 门户路径(W4 嵌套路径,方案 §8.4/D2):顶层=code;子站=父path+'/'+code。
* 门户 URL 唯一标识(/portal/{path}/**),全站唯一。
*/
@Column(name = "path", nullable = false, length = 191)
private String path = "";
/** 上级站点ID(0=顶层校站点;院系子站挂主站 id,层级固定两层) */
@Column(name = "parent_site_id", nullable = false)
private Integer parentSiteId = 0;
/** 学校ID(逻辑外键 → UserBase sys_group.id) */
@Column(name = "sch_id")
private Integer schId;
/** 学校名称(冗余展示) */
@Column(name = "sch_name", length = 128)
private String schName;
/** 站点名称 */
@Column(name = "name", nullable = false, length = 128)
private String name;
/** 皮肤包名(classpath:/templates/themes/{theme}/) */
@Column(name = "theme", length = 64)
private String theme = "default";
/** 站点配置 JSON(logo/联系方式/页脚/domain/public_base_url) */
@Column(name = "config_json", columnDefinition = "JSON")
private String configJson;
/** 状态:0=停用 1=启用 */
@Column(name = "status", nullable = false)
private Integer status = 1;
}
@@ -0,0 +1,36 @@
package com.yunhai.cms.enums;
/**
* 文章状态:MVP 草稿→发布 两态 + 已下架;审核状态机为演进项(方案 §2/§5.2)。
*/
public enum ArticleStatusEnum {
/** 草稿(publish_time 为未来时间时即"定时发布"待触发) */
DRAFT(0),
/** 已发布 */
PUBLISHED(1),
/** 已下架 */
OFFLINE(2);
private final int code;
ArticleStatusEnum(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static ArticleStatusEnum byCode(Integer code) {
if (code == null) {
return DRAFT;
}
for (ArticleStatusEnum e : values()) {
if (e.code == code) {
return e;
}
}
return DRAFT;
}
}
@@ -0,0 +1,50 @@
package com.yunhai.cms.enums;
/**
* 栏目类型(旧 CMS3.0"频道"层收敛而来,方案 §2/§5.2):
* 文章列表 / 单页 / 外链 三类覆盖原频道全部用法。
*/
public enum CategoryTypeEnum {
/** 文章列表 */
LIST(1),
/** 单页(直接渲染 content) */
SINGLE(2),
/** 外链(门户 302 跳转) */
LINK(3);
private final int code;
CategoryTypeEnum(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static CategoryTypeEnum byCode(Integer code) {
if (code == null) {
return LIST;
}
for (CategoryTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return LIST;
}
/** 严格解析:未知/空返回 null(用户输入校验用;渲染期脏数据仍走 byCode 宽松回退) */
public static CategoryTypeEnum ofCode(Integer code) {
if (code == null) {
return null;
}
for (CategoryTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return null;
}
}
@@ -0,0 +1,50 @@
package com.yunhai.cms.enums;
/**
* 跳转类型(轮播 link_type 等通用;方案 §5.1 表5)。
* 计划中的 LinkTypeEnum/BannerLinkTypeEnum 语义重合,合并为本枚举。
*/
public enum LinkTypeEnum {
/** 文章(link_value=文章ID,门户渲染为 /portal/{code}/a/{id}) */
ARTICLE(1),
/** 栏目(link_value=栏目ID,渲染为 /portal/{code}/c/{id}) */
CATEGORY(2),
/** 外链(link_value=完整 URL) */
EXTERNAL(3);
private final int code;
LinkTypeEnum(int code) {
this.code = code;
}
public int getCode() {
return code;
}
public static LinkTypeEnum byCode(Integer code) {
if (code == null) {
return ARTICLE;
}
for (LinkTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return ARTICLE;
}
/** 严格解析:未知/空返回 null(用户输入校验用;渲染期脏数据仍走 byCode 宽松回退) */
public static LinkTypeEnum ofCode(Integer code) {
if (code == null) {
return null;
}
for (LinkTypeEnum e : values()) {
if (e.code == code) {
return e;
}
}
return null;
}
}
@@ -0,0 +1,23 @@
package com.yunhai.cms.event;
/**
* 文章内容变更事件(保存修改/下架/删除)。事务提交后触发缓存失效(方案 §8.4)。
*/
public class ArticleChangedEvent {
private final Integer articleId;
private final Integer siteId;
public ArticleChangedEvent(Integer articleId, Integer siteId) {
this.articleId = articleId;
this.siteId = siteId;
}
public Integer getArticleId() {
return articleId;
}
public Integer getSiteId() {
return siteId;
}
}
@@ -0,0 +1,31 @@
package com.yunhai.cms.event;
/**
* 文章发布事件(方案 §7.4 微信预留挂点):事务提交后由 AFTER_COMMIT 监听器消费。
* 本期监听方 = 缓存失效 + WeChatPushService(Noop 实现,零外联);
* 启用《微信平台对接方案》时仅替换 Noop 实现,零表结构变更。
*/
public class ArticlePublishedEvent {
private final Integer articleId;
private final Integer siteId;
private final String title;
public ArticlePublishedEvent(Integer articleId, Integer siteId, String title) {
this.articleId = articleId;
this.siteId = siteId;
this.title = title;
}
public Integer getArticleId() {
return articleId;
}
public Integer getSiteId() {
return siteId;
}
public String getTitle() {
return title;
}
}
@@ -0,0 +1,26 @@
package com.yunhai.cms.event;
/**
* 站点内容/结构变更事件:站点本身、栏目树、轮播、友链 任一变更即发,
* 门户站点快照缓存(cms:site)整体失效 + 列表缓存清除(方案 §8.4)。
*/
public class SiteContentChangedEvent {
private final Integer siteId;
/** 变更来源说明(日志用):site/category/banner/link */
private final String scope;
public SiteContentChangedEvent(Integer siteId, String scope) {
this.siteId = siteId;
this.scope = scope;
}
public Integer getSiteId() {
return siteId;
}
public String getScope() {
return scope;
}
}
@@ -0,0 +1,63 @@
package com.yunhai.cms.event.listener;
import com.yunhai.cms.config.CacheNames;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.event.SiteContentChangedEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 门户缓存失效监听(方案 §8.4):全部 AFTER_COMMIT(fallbackExecution=true 兼容
* 定时任务等无事务路径),失效失败仅记日志,靠 TTL 自愈。
*
* <p>cms:list / cms:site 的 key 含分页与站点维度,MVP 直接整缓存 clear:
* 单实例 Caffeine、站点 ≤20,clear 成本可忽略;精确 evict 留给 Redis 演进。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PortalCacheEvictListener {
private final CacheManager cacheManager;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onArticlePublished(ArticlePublishedEvent event) {
evictArticle(event.getArticleId());
clear(CacheNames.LIST);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onArticleChanged(ArticleChangedEvent event) {
evictArticle(event.getArticleId());
clear(CacheNames.LIST);
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onSiteContentChanged(SiteContentChangedEvent event) {
clear(CacheNames.SITE);
clear(CacheNames.LIST);
}
// 事项域缓存(cms:svcItem):V2.0 拆分后无本地写事件,remote 模式靠 TTL 过期
// (办事大厅变更后最多滞后 5min);ServiceItemChangedEvent 已随事项管理能力迁出。
private void evictArticle(Integer articleId) {
Cache cache = cacheManager.getCache(CacheNames.ARTICLE);
if (cache != null && articleId != null) {
cache.evict(articleId);
}
}
private void clear(String cacheName) {
Cache cache = cacheManager.getCache(cacheName);
if (cache != null) {
cache.clear();
}
}
}
@@ -0,0 +1,24 @@
package com.yunhai.cms.event.listener;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.service.WeChatPushService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.event.TransactionPhase;
import org.springframework.transaction.event.TransactionalEventListener;
/**
* 文章发布 → 微信推送挂点(方案 §7.4 后端层预留)。
* 事务提交后才触发,保证推送方读到的数据已落库;推送失败不影响业务事务。
*/
@Component
@RequiredArgsConstructor
public class WeChatPushListener {
private final WeChatPushService weChatPushService;
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT, fallbackExecution = true)
public void onArticlePublished(ArticlePublishedEvent event) {
weChatPushService.onArticlePublished(event);
}
}
@@ -0,0 +1,64 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 文章保存(草稿,content 先过 jsoup 白名单,方案 §9.2)。
* 发布语义见 ArticleService.publish:publishTime 为未来时间即"定时发布"。
*/
@Data
public class ArticleSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
@NotNull(message = "所属栏目不得为空")
private Integer categoryId;
@NotBlank(message = "标题不得为空")
@Size(max = 200, message = "标题过长")
private String title;
/** 摘要(空=服务端截取正文) */
@Size(max = 500, message = "摘要过长")
private String summary;
/** 正文 HTML(服务端 jsoup 清洗后入库) */
private String content;
@Size(max = 500)
private String coverImage;
/** 作者姓名(展示用;账号发布时自动从 BoaUser 填充) */
@Size(max = 64)
private String authorName;
@Size(max = 128)
private String source;
/** 置顶:0/1 */
private Integer topmost = 0;
/** 定时发布时间(空=不指定,发布动作时取当前时间) */
private LocalDateTime publishTime;
/** 定时下架时间(可空) */
private LocalDateTime offlineTime;
/**
* 同步到公众号开关(微信预留入口,本期恒禁用,方案 §7.4)。
* 仅作占位字段,后端不消费;启用后写 ext_json.wechat。
*/
private Boolean syncWechat = false;
/** 本次编辑上传的附件 id 列表(保存时回填 article_id 归属,方案 §5.1 表4) */
private java.util.List<Integer> attachmentIds;
}
@@ -0,0 +1,73 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
import java.time.LocalDateTime;
/**
* 轮播保存(校验 link_type/link_value,方案 §9.2;docs/20 D-3/D-6 扩展 topic/event 位字段)。
*/
@Data
public class BannerSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
/** 展示位(home=首页轮播) */
@Size(max = 32)
private String position = "home";
@NotBlank(message = "标题不得为空")
@Size(max = 200, message = "标题过长")
private String title;
@NotBlank(message = "图片不得为空")
@Size(max = 500)
private String imageUrl;
/** 1=文章 2=栏目 3=外链 */
@NotNull(message = "跳转类型不得为空")
private Integer linkType;
/** 跳转目标:文章ID/栏目ID/URL(服务端按类型校验必填与存在性) */
@Size(max = 500, message = "跳转目标过长")
private String linkValue;
private Integer sortNo = 0;
/** 上线时间(空=立即) */
private LocalDateTime startTime;
/** 下线时间(可空) */
private LocalDateTime endTime;
/** 0=停用 1=启用 */
private Integer status = 1;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展(仅对应位消费,其余位忽略)=====
/** 专题大卡描述(position=topic) */
@Size(max = 500, message = "专题描述过长")
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
@Size(max = 100, message = "按钮文字过长")
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
@Size(max = 500, message = "按钮地址过长")
private String ctaUrl;
/** 活动日期(position=event) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
@Size(max = 200, message = "活动地点过长")
private String location;
}
@@ -0,0 +1,58 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 栏目保存(新增 id 空 / 修改 id 必填,方案 §9.2)。
*/
@Data
public class CategorySaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
/** 父栏目ID(0=根) */
@NotNull(message = "父栏目不得为空")
private Integer parentId;
@NotBlank(message = "栏目名称不得为空")
@Size(max = 64, message = "栏目名称过长")
private String name;
/** 1=文章列表 2=单页 3=外链 */
@NotNull(message = "栏目类型不得为空")
private Integer type;
/** 外链地址(type=3 必填,服务端校验) */
@Size(max = 500, message = "外链地址过长")
private String linkUrl;
/** 栏目模板覆盖(预留能力) */
@Size(max = 64)
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus,服务端白名单校验) */
@Size(max = 32)
private String homeStyle;
/** 单页栏目内容(type=2 有效,服务端 jsoup 清洗后入库) */
private String content;
private Integer sortNo = 0;
@Size(max = 200)
private String seoTitle;
@Size(max = 500)
private String seoDescription;
/** 0=隐藏 1=显示 */
private Integer status = 1;
}
@@ -0,0 +1,25 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
import java.util.List;
/**
* 子栏目排序入参(UserBase SysGroupSortRequest 同语义:ids 顺序即 sortNo 1..n)。
*/
@Data
public class CategorySortRequest {
/** 站点 ID(权限校验用) */
@NotNull(message = "站点ID不能为空")
private Integer siteId;
/** 父栏目 ID(0=根栏目下的排序) */
@NotNull(message = "父栏目ID不能为空")
private Integer parentId;
/** 期望的、parentId 直接子栏目有序 ID 列表(按传入顺序生效) */
@NotNull(message = "子栏目ID列表不能为空")
private List<Integer> ids;
}
@@ -0,0 +1,18 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotNull;
/**
* 通用删除入参:id + siteId(siteId 用于站点归属权限校验,避免越权 id 枚举)。
*/
@Data
public class DeleteRequest {
@NotNull(message = "id 不得为空")
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
}
@@ -0,0 +1,41 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 友链保存(方案 §9.2)。
*/
@Data
public class LinkSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
/** 分组(上级部门/兄弟院校/合作单位…,空=default) */
@Size(max = 64, message = "分组名过长")
private String groupName = "default";
@NotBlank(message = "链接名称不得为空")
@Size(max = 128, message = "链接名称过长")
private String name;
/** Logo(可空,文字链) */
@Size(max = 500)
private String logoUrl;
@NotBlank(message = "链接地址不得为空")
@Size(max = 500, message = "链接地址过长")
private String linkUrl;
private Integer sortNo = 0;
/** 0=隐藏 1=显示 */
private Integer status = 1;
}
@@ -0,0 +1,42 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Size;
/**
* 名师风采人物保存(docs/20 D-7)。
*/
@Data
public class PersonSaveRequest {
/** 修改时必填 */
private Integer id;
@NotNull(message = "siteId 不得为空")
private Integer siteId;
@NotBlank(message = "姓名不得为空")
@Size(max = 100, message = "姓名过长")
private String name;
@Size(max = 100, message = "头衔过长")
private String title;
@Size(max = 500, message = "简介过长")
private String bio;
/** 头像(/cms/upload/** 或 http(s);可空 = 首字徽章兜底) */
@Size(max = 500)
private String avatar;
/** 所属栏目(可空 = 未分组;须属于本站点) */
private Integer categoryId;
private Integer sortNo = 0;
/** 0=停用 1=启用 */
private Integer status = 1;
}
@@ -0,0 +1,57 @@
package com.yunhai.cms.model.dto;
import lombok.Data;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.Pattern;
import javax.validation.constraints.Size;
import java.util.Map;
/**
* 站点保存(仅超管可见,方案 §9.1/§7.2)。
* MVP 1-2 校:初始化脚本建站即可,本接口为超管后台兜底入口。
*/
@Data
public class SiteSaveRequest {
/** 修改时必填 */
private Integer id;
@NotBlank(message = "站点编码不得为空")
@Pattern(regexp = "^[a-z0-9][a-z0-9-]{1,62}[a-z0-9]$", message = "编码仅限小写字母/数字/短横线")
private String code;
/** 上级站点ID(空或 0=顶层校站点;院系子站挂主站 id,《院系子站方案》) */
private Integer parentSiteId;
private Integer schId;
@Size(max = 128)
private String schName;
@NotBlank(message = "站点名称不得为空")
@Size(max = 128, message = "站点名称过长")
private String name;
/** 皮肤包名(空=default) */
@Size(max = 64)
private String theme;
/**
* 站点配置 JSON(logo/联系方式/页脚;保留键 domain/public_base_url/wechat,方案 §8.6)。
* 服务端做 JSON 合法性校验。
*/
private String configJson;
/**
* 主题微调参数(方案 §5.3)。
*
* @deprecated 工作项 T:参数微调已上移为皮肤包级({@code PUT /api/theme/{name}/meta} 的
* {@code paramValues}),站点不再持有参数值,服务端忽略本字段。字段保留仅为老前端不报错。
*/
@Deprecated
private Map<String, String> themeParams;
/** 0=停用 1=启用 */
private Integer status = 1;
}
@@ -0,0 +1,23 @@
package com.yunhai.cms.model.dto;
import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
/** W1 皮肤包派生请求(POST /api/theme/derive);任务四增选填 title/description(写入新包 theme.json) */
@Data
public class ThemeDeriveRequest {
/** 派生源包名(内置或外部) */
private String source;
/** 新包名(外部包目录名,theme.json name 同步改写) */
private String name;
/** 显示名称(选填;留空沿用源包 title) */
private String title;
/** 描述(选填;null=沿用源包,""=清空) */
private String description;
}
@@ -0,0 +1,26 @@
package com.yunhai.cms.model.dto;
import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/** W1 皮肤包元数据编辑请求(PUT /api/theme/{name}/meta);任务四增 description 并放开内置包改名(走 .meta 覆盖层) */
@Data
public class ThemeMetaRequest {
private String title;
/** 描述(null=不改;""=清空) */
private String description;
/** 参数 schema 整体替换(key/label/type/默认值/色值合法性服务端强校验);null=不改;内置包拒改 */
private List<ThemeParam> params;
/**
* 包级参数值(工作项 T:参数微调迁皮肤包级):null=不改,非 null=整体替换(空 Map=恢复 theme.json 默认值);
* 未声明 key 与非法值按包 schema 丢弃;内置包与外部包统一写 .meta 覆盖层。
*/
private Map<String, String> paramValues;
}
@@ -0,0 +1,72 @@
package com.yunhai.cms.model.portal;
import com.yunhai.cms.entity.CmsArticle;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 门户文章详情(cms:article 缓存体;已发布文章才缓存,null 哨兵 30s 防穿透)。
*/
@Data
public class PortalArticleDetail {
private Integer id;
private Integer siteId;
private Integer categoryId;
private String title;
/** 正文(入库前已 jsoup 清洗,模板 th:utext 输出) */
private String content;
private String summary;
private String coverImage;
private String authorName;
private String source;
private LocalDateTime publishTime;
private Integer viewCount;
public static PortalArticleDetail of(CmsArticle a) {
PortalArticleDetail detail = new PortalArticleDetail();
detail.setId(a.getId());
detail.setSiteId(a.getSiteId());
detail.setCategoryId(a.getCategoryId());
detail.setTitle(a.getTitle());
detail.setContent(a.getContent());
detail.setSummary(a.getSummary());
detail.setCoverImage(a.getCoverImage());
detail.setAuthorName(a.getAuthorName());
detail.setSource(a.getSource());
detail.setPublishTime(a.getPublishTime());
detail.setViewCount(a.getViewCount());
return detail;
}
/**
* 渲染副本(B-1):本对象是 cms:article 缓存体(两种暴露形态共享),
* 正文上传地址需按当前请求基准重写,故拷贝后替换 content,不回写缓存。
*/
public PortalArticleDetail withContent(String content) {
PortalArticleDetail copy = new PortalArticleDetail();
copy.id = id;
copy.siteId = siteId;
copy.categoryId = categoryId;
copy.title = title;
copy.content = content;
copy.summary = summary;
copy.coverImage = coverImage;
copy.authorName = authorName;
copy.source = source;
copy.publishTime = publishTime;
copy.viewCount = viewCount;
return copy;
}
}
@@ -0,0 +1,71 @@
package com.yunhai.cms.model.portal;
import com.yunhai.cms.entity.CmsBanner;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 门户轮播项(启用态;定时上下线由渲染方按时间窗过滤)。
*/
@Data
public class PortalBanner {
private Integer id;
/** 展示位(home=首页轮播) */
private String position;
private String title;
private String imageUrl;
/** 1=文章 2=栏目 3=外链 */
private Integer linkType;
/** 跳转目标原值(文章ID/栏目ID/URL),渲染时解析为完整 URL */
private String linkValue;
private Integer sortNo;
/** 上线时间(渲染方过滤) */
private LocalDateTime startTime;
/** 下线时间(渲染方过滤) */
private LocalDateTime endTime;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
/** 专题大卡描述(position=topic) */
private String description;
/** 专题大卡 CTA 按钮文字(position=topic) */
private String ctaText;
/** 专题大卡 CTA 跳转地址(position=topic) */
private String ctaUrl;
/** 活动日期(position=event,学术日历大日期块) */
private LocalDateTime eventDate;
/** 活动地点(position=event) */
private String location;
public static PortalBanner of(CmsBanner b) {
PortalBanner item = new PortalBanner();
item.setId(b.getId());
item.setPosition(b.getPosition());
item.setTitle(b.getTitle());
item.setImageUrl(b.getImageUrl());
item.setLinkType(b.getLinkType());
item.setLinkValue(b.getLinkValue());
item.setSortNo(b.getSortNo());
item.setStartTime(b.getStartTime());
item.setEndTime(b.getEndTime());
item.setDescription(b.getDescription());
item.setCtaText(b.getCtaText());
item.setCtaUrl(b.getCtaUrl());
item.setEventDate(b.getEventDate());
item.setLocation(b.getLocation());
return item;
}
}
@@ -0,0 +1,74 @@
package com.yunhai.cms.model.portal;
import com.yunhai.cms.entity.CmsCategory;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户栏目节点(仅启用项;单页栏目带 content)。
*/
@Data
public class PortalCategory {
private Integer id;
private Integer parentId;
private String name;
/** 1=文章列表 2=单页 3=外链 */
private Integer type;
private String linkUrl;
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
private String seoTitle;
private String seoDescription;
/** 单页内容(type=2,已清洗 HTML) */
private String content;
private List<PortalCategory> children = new ArrayList<>();
public static PortalCategory of(CmsCategory c) {
PortalCategory node = new PortalCategory();
node.setId(c.getId());
node.setParentId(c.getParentId());
node.setName(c.getName());
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setSeoTitle(c.getSeoTitle());
node.setSeoDescription(c.getSeoDescription());
node.setContent(c.getContent());
return node;
}
/**
* 渲染副本(B-1):本对象挂在 cms:site 快照缓存上,
* 单页正文上传地址按当前请求基准重写需在拷贝上进行,不回写缓存。
*/
public PortalCategory withContent(String content) {
PortalCategory copy = new PortalCategory();
copy.id = id;
copy.parentId = parentId;
copy.name = name;
copy.type = type;
copy.linkUrl = linkUrl;
copy.template = template;
copy.homeStyle = homeStyle;
copy.seoTitle = seoTitle;
copy.seoDescription = seoDescription;
copy.content = content;
copy.children = children;
return copy;
}
}
@@ -0,0 +1,27 @@
package com.yunhai.cms.model.portal;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户友链分组(页脚/首页区块渲染,方案 §8.1:不设独立路由)。
*/
@Data
public class PortalLinkGroup {
private String groupName;
private List<PortalLink> links = new ArrayList<>();
@Data
public static class PortalLink {
private String name;
private String logoUrl;
private String linkUrl;
}
}
@@ -0,0 +1,47 @@
package com.yunhai.cms.model.portal;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 门户列表页数据(cms:list 缓存体,key=categoryId:page)。
*/
@Data
public class PortalListData {
private Integer categoryId;
private Integer siteId;
private List<Item> items = new ArrayList<>();
private long total;
private int page;
private int pageSize;
private int totalPages;
@Data
public static class Item {
private Integer id;
private String title;
private String summary;
private String coverImage;
/** 来源(转载出处,docs/11:媒体聚焦来源徽标用;可空) */
private String source;
private LocalDateTime publishTime;
private Integer topmost;
}
}
@@ -0,0 +1,23 @@
package com.yunhai.cms.model.portal;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 门户左侧栏目导航(docs/11):模块存在二级子栏目时,栏目页/详情页左侧
* 展示"模块名 + 全部二级子栏目",当前位高亮。纯展示模型,由站点快照树推导。
*/
@Data
public class PortalSideNav {
/** 顶级模块(侧栏头) */
private PortalCategory top;
/** 全部二级子栏目(快照序,列表/单页/外链型均列出) */
private List<PortalCategory> items = new ArrayList<>();
/** 当前高亮栏目 id:当前为二级时=当前栏目;三级及更深时=所在二级分支;一级直点=null */
private Integer activeId;
}
@@ -0,0 +1,43 @@
package com.yunhai.cms.model.portal;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.utils.ExtJsonUtils;
import lombok.Data;
import java.util.Map;
/**
* 院系子站精简态(门户"专业院系"下拉数据源,《院系子站方案》)。
* 链接不进快照:由渲染方经 {@link com.yunhai.cms.utils.PortalUrlHelper#siteHome}
* 生成(该站 public_base_url 优先,未配置按当前请求基准派生,方案 §8.6 前置约定2)。
*/
@Data
public class PortalSiteBrief {
private Integer id;
private String code;
/** 门户路径(W4:嵌套 path,下拉链接用) */
private String path;
private String name;
/** 站点 logo(config_json.logo,下拉项可选展示) */
private String logoUrl;
/** 对外基地址(config_json.public_base_url,未配置时渲染方按当前请求派生) */
private String publicBaseUrl;
public static PortalSiteBrief of(CmsSite site) {
PortalSiteBrief brief = new PortalSiteBrief();
brief.setId(site.getId());
brief.setCode(site.getCode());
brief.setPath(site.getPath());
brief.setName(site.getName());
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
brief.setLogoUrl(ExtJsonUtils.getString(config, "logo"));
brief.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
return brief;
}
}
@@ -0,0 +1,158 @@
package com.yunhai.cms.model.portal;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.utils.ExtJsonUtils;
import lombok.Data;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 门户站点快照(cms:site 缓存体,方案 §8.4):
* 站点配置 + 栏目树 + 轮播位 + 友链分组 + 同校院系子站。文章数据不进快照(cms:list / cms:article 独立缓存)。
*/
@Data
public class PortalSiteSnapshot {
private Integer id;
private String code;
/** 门户路径(W4:顶层=code;子站=父path/code) */
private String path;
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
private Integer parentSiteId;
private String name;
private String schName;
/** 皮肤包名(缺省/default 回退已在装配时处理) */
private String theme;
/** 站点配置(logo/contact/footer 等,模板经 config['key'] 取用) */
private Map<String, Object> config;
/** 站点绑定域名(config_json.domain,台账用) */
private String domain;
/** 对外基地址(config_json.public_base_url,未配置时渲染方按请求派生) */
private String publicBaseUrl;
/**
* 服务大厅基地址(config_json.svc_hall_base,事项库 §3.7):非空时指南页"我要办理"
* 按其生成 svhall 发起页 URL(优先级低于事项手填 ext_json.apply_url);空=不生成。
*/
private String svcHallBase;
/** 启用栏目树(status=1,已组树) */
private List<PortalCategory> categories = new ArrayList<>();
/** 启用轮播(status=1 全部位,时间窗过滤在渲染方) */
private List<PortalBanner> banners = new ArrayList<>();
/** 启用友链分组 */
private List<PortalLinkGroup> linkGroups = new ArrayList<>();
/** 同校院系子站("专业院系"下拉数据源;顶层站无子站/子站的兄弟站集合,空则模板不渲染) */
private List<PortalSiteBrief> colleges = new ArrayList<>();
/** 微调参数(仅所选主题 theme.json 声明过的 key 且值合法,方案 §5.3) */
private Map<String, String> themeParams = new LinkedHashMap<>();
/** 服务端预构建的 :root{...} CSS 变量覆盖串(无有效参数时为空串,方案 §5.3) */
private String themeVarsCss = "";
/** 站点级自定义样式(B3:config_json.custom_css,保存时经服务端黑名单校验,存量直读) */
private String customCss = "";
/**
* 首页布局(docs/16 主线B:config_json.home_layout,{version, blocks:[…]})。
* null = 未编排布局(渲染端回落「全部栏目自动渲染」现状,老站零迁移);
* 解析容错:键缺失/非法结构一律 null,绝不让脏布局打挂门户。
*/
private Map<String, Object> homeLayout;
/**
* 页脚多行地址(docs/11:config_json.footer_addresses 按 \r?\n 拆分去空行,装配期派生随快照缓存,
* 规避模板内拆分的表达式平台差异)。
*/
private List<String> footerAddressLines = new ArrayList<>();
public static PortalSiteSnapshot of(CmsSite site) {
PortalSiteSnapshot snapshot = new PortalSiteSnapshot();
snapshot.setId(site.getId());
snapshot.setCode(site.getCode());
snapshot.setPath(site.getPath());
snapshot.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
snapshot.setName(site.getName());
snapshot.setSchName(site.getSchName());
snapshot.setTheme(site.getTheme() == null || site.getTheme().isEmpty()
? com.yunhai.cms.theme.ThemeRegistry.DEFAULT_THEME : site.getTheme());
Map<String, Object> config = ExtJsonUtils.parse(site.getConfigJson());
snapshot.setConfig(config);
snapshot.setDomain(ExtJsonUtils.getString(config, "domain"));
snapshot.setPublicBaseUrl(ExtJsonUtils.getString(config, "public_base_url"));
snapshot.setSvcHallBase(ExtJsonUtils.getString(config, "svc_hall_base"));
snapshot.setCustomCss(ExtJsonUtils.getString(config, "custom_css"));
snapshot.setHomeLayout(normalizeHomeLayout(config.get("home_layout")));
snapshot.setFooterAddressLines(splitAddressLines(ExtJsonUtils.getString(config, "footer_addresses")));
return snapshot;
}
/** home_layout 容错归一:对象且 blocks 为数组才生效,否则视为无布局(回落态) */
private static Map<String, Object> normalizeHomeLayout(Object raw) {
if (!(raw instanceof Map)) {
return null;
}
Object blocks = ((Map<?, ?>) raw).get("blocks");
return blocks instanceof List ? (Map<String, Object>) raw : null;
}
/** footer_addresses 多行文本拆行(\r?\n 分隔,去空行;空值返回空列表) */
private static List<String> splitAddressLines(String raw) {
List<String> lines = new ArrayList<>();
if (raw == null || raw.trim().isEmpty()) {
return lines;
}
for (String line : raw.split("\r?\n")) {
String trimmed = line.trim();
if (!trimmed.isEmpty()) {
lines.add(trimmed);
}
}
return lines;
}
/**
* 预览用浅拷贝(方案 §5.5):集合字段共享只读引用;覆盖 theme/themeParams/themeVarsCss
* 的操作由预览方在拷贝上进行,不影响缓存中的原快照。
*/
public PortalSiteSnapshot shallowCopy() {
PortalSiteSnapshot copy = new PortalSiteSnapshot();
copy.setId(id);
copy.setCode(code);
copy.setPath(path);
copy.setParentSiteId(parentSiteId);
copy.setName(name);
copy.setSchName(schName);
copy.setTheme(theme);
copy.setConfig(config);
copy.setDomain(domain);
copy.setPublicBaseUrl(publicBaseUrl);
copy.setSvcHallBase(svcHallBase);
copy.setCategories(categories);
copy.setBanners(banners);
copy.setLinkGroups(linkGroups);
copy.setColleges(colleges);
copy.setThemeParams(themeParams);
copy.setThemeVarsCss(themeVarsCss);
copy.setCustomCss(customCss);
copy.setHomeLayout(homeLayout);
copy.setFooterAddressLines(footerAddressLines);
return copy;
}
}
@@ -0,0 +1,29 @@
package com.yunhai.cms.model.query;
import lombok.Data;
import java.util.List;
/**
* 文章分页查询(方案 §9.2:siteId/categoryId/status/关键字)。
*/
@Data
public class ArticlePageQuery {
private Integer siteId;
/** 栏目 id(可空;支持多选时前端传逗号分隔,MVP 单选/空=全部) */
private List<Integer> categoryIds;
/** 状态:空=全部,0=草稿 1=已发布 2=已下架 */
private Integer status;
/** 标题关键字 */
private String keyword;
/** 页码,1 起 */
private Integer page = 1;
/** 页长 */
private Integer pageSize = 10;
}
@@ -0,0 +1,62 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsArticle;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文章编辑回显 VO(含正文与扩展列)。
*/
@Data
public class ArticleEditVo {
private Integer id;
private Integer siteId;
private Integer categoryId;
private String title;
private String summary;
/** 正文 HTML(已清洗入库内容原样回显) */
private String content;
private String coverImage;
private String authorName;
private String source;
private Integer status;
private Integer topmost;
private LocalDateTime publishTime;
private LocalDateTime offlineTime;
/** 扩展列 JSON 原文(wechat/albums 预留键) */
private String extJson;
public static ArticleEditVo of(CmsArticle a) {
ArticleEditVo vo = new ArticleEditVo();
vo.setId(a.getId());
vo.setSiteId(a.getSiteId());
vo.setCategoryId(a.getCategoryId());
vo.setTitle(a.getTitle());
vo.setSummary(a.getSummary());
vo.setContent(a.getContent());
vo.setCoverImage(a.getCoverImage());
vo.setAuthorName(a.getAuthorName());
vo.setSource(a.getSource());
vo.setStatus(a.getStatus());
vo.setTopmost(a.getTopmost());
vo.setPublishTime(a.getPublishTime());
vo.setOfflineTime(a.getOfflineTime());
vo.setExtJson(a.getExtJson());
return vo;
}
}
@@ -0,0 +1,62 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsArticle;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 文章分页行(列表页,方案 §9.1 文章列表)。
*/
@Data
public class ArticlePageItem {
private Integer id;
private Integer siteId;
private Integer categoryId;
private String categoryName;
private String title;
private String summary;
private String coverImage;
/** 0=草稿 1=已发布 2=已下架 */
private Integer status;
private Integer topmost;
/** 发布时间(草稿且未来时间为"定时发布") */
private LocalDateTime publishTime;
private LocalDateTime offlineTime;
private Integer viewCount;
private LocalDateTime createTime;
private String createBy;
public static ArticlePageItem of(CmsArticle a, String categoryName) {
ArticlePageItem item = new ArticlePageItem();
item.setId(a.getId());
item.setSiteId(a.getSiteId());
item.setCategoryId(a.getCategoryId());
item.setCategoryName(categoryName);
item.setTitle(a.getTitle());
item.setSummary(a.getSummary());
item.setCoverImage(a.getCoverImage());
item.setStatus(a.getStatus());
item.setTopmost(a.getTopmost());
item.setPublishTime(a.getPublishTime());
item.setOfflineTime(a.getOfflineTime());
item.setViewCount(a.getViewCount());
item.setCreateTime(a.getCreateTime());
item.setCreateBy(a.getCreateBy());
return item;
}
}
@@ -0,0 +1,49 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsAttachment;
import lombok.Data;
/**
* 附件 VO(上传返回 + 附件管理列表)。
*/
@Data
public class AttachmentVo {
private Integer id;
private Integer siteId;
private Integer articleId;
/** 场景引用类型(B2):banner/link/site;空=无场景引用 */
private String refType;
private String fileName;
/** Web 访问 URL(编辑器/封面/轮播直接引用) */
private String url;
private Long fileSize;
private String mimeType;
private String createTime;
/** 是否未使用(B2:无文章引用且无场景引用) */
private boolean orphan;
public static AttachmentVo of(CmsAttachment a) {
AttachmentVo vo = new AttachmentVo();
vo.setId(a.getId());
vo.setSiteId(a.getSiteId());
vo.setArticleId(a.getArticleId());
vo.setRefType(a.getRefType());
vo.setFileName(a.getFileName());
vo.setUrl(a.getWebUrl());
vo.setFileSize(a.getFileSize());
vo.setMimeType(a.getMimeType());
vo.setCreateTime(a.getCreateTime() == null ? null : a.getCreateTime().toString());
vo.setOrphan(a.getArticleId() == null && a.getRefType() == null);
return vo;
}
}
@@ -0,0 +1,45 @@
package com.yunhai.cms.model.vo;
import com.yunhai.platform.utils.model.BoaRoleInfo;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.Builder;
import lombok.Data;
import java.util.List;
/**
* 管理端登录态:CAS 会话用户 + 可管理站点 + 是否超管(方案 §7.2 权限规则的客户端镜像)。
*/
@Data
@Builder
public class AuthUserVo {
/** 登录账号 */
private String account;
/** 姓名 */
private String name;
/** 当前角色(roleId/roleName/schId/schName) */
private BoaRoleInfo currentRole;
/** 是否平台超管(BoaUser.type == 1,平台约定) */
private boolean superAdmin;
/** 可管理的站点(超管=全部;校级=本校站点) */
private List<SiteVo> sites;
/** 角色集合(前端判断用) */
private List<Integer> roleIds;
public static AuthUserVo of(BoaUser user, boolean superAdmin, List<SiteVo> sites) {
return AuthUserVo.builder()
.account(user.getAccount())
.name(user.getName())
.currentRole(user.getCurrentRole())
.superAdmin(superAdmin)
.sites(sites)
.roleIds(user.getRoleIds())
.build();
}
}
@@ -0,0 +1,64 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsBanner;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 轮播 VO。
*/
@Data
public class BannerVo {
private Integer id;
private Integer siteId;
private String position;
private String title;
private String imageUrl;
/** 1=文章 2=栏目 3=外链 */
private Integer linkType;
private String linkValue;
private Integer sortNo;
private LocalDateTime startTime;
private LocalDateTime endTime;
private Integer status;
// ===== docs/20 D-3/D-6:topic/event 展示位扩展字段 =====
private String description;
private String ctaText;
private String ctaUrl;
private java.time.LocalDateTime eventDate;
private String location;
public static BannerVo of(CmsBanner b) {
BannerVo vo = new BannerVo();
vo.setId(b.getId());
vo.setSiteId(b.getSiteId());
vo.setPosition(b.getPosition());
vo.setTitle(b.getTitle());
vo.setImageUrl(b.getImageUrl());
vo.setLinkType(b.getLinkType());
vo.setLinkValue(b.getLinkValue());
vo.setSortNo(b.getSortNo());
vo.setStartTime(b.getStartTime());
vo.setEndTime(b.getEndTime());
vo.setStatus(b.getStatus());
vo.setDescription(b.getDescription());
vo.setCtaText(b.getCtaText());
vo.setCtaUrl(b.getCtaUrl());
vo.setEventDate(b.getEventDate());
vo.setLocation(b.getLocation());
return vo;
}
}
@@ -0,0 +1,64 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsCategory;
import lombok.Data;
import java.util.ArrayList;
import java.util.List;
/**
* 栏目树节点(管理端树控件与门户导航共用结构)。
*/
@Data
public class CategoryTreeNode {
private Integer id;
private Integer parentId;
private Integer siteId;
private String name;
/** 1=文章列表 2=单页 3=外链 */
private Integer type;
private String linkUrl;
private String template;
/** 首页展示形态(docs/11:空=默认卡 / news_grid / media_focus) */
private String homeStyle;
/** 单页栏目内容(type=2 时编辑/渲染用) */
private String content;
private Integer sortNo;
private String seoTitle;
private String seoDescription;
/** 0=隐藏 1=显示 */
private Integer status;
private List<CategoryTreeNode> children = new ArrayList<>();
public static CategoryTreeNode of(CmsCategory c) {
CategoryTreeNode node = new CategoryTreeNode();
node.setId(c.getId());
node.setParentId(c.getParentId());
node.setSiteId(c.getSiteId());
node.setName(c.getName());
node.setType(c.getType());
node.setLinkUrl(c.getLinkUrl());
node.setTemplate(c.getTemplate());
node.setHomeStyle(c.getHomeStyle());
node.setContent(c.getContent());
node.setSortNo(c.getSortNo());
node.setSeoTitle(c.getSeoTitle());
node.setSeoDescription(c.getSeoDescription());
node.setStatus(c.getStatus());
return node;
}
}
@@ -0,0 +1,40 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsLink;
import lombok.Data;
/**
* 友链 VO。
*/
@Data
public class LinkVo {
private Integer id;
private Integer siteId;
private String groupName;
private String name;
private String logoUrl;
private String linkUrl;
private Integer sortNo;
private Integer status;
public static LinkVo of(CmsLink l) {
LinkVo vo = new LinkVo();
vo.setId(l.getId());
vo.setSiteId(l.getSiteId());
vo.setGroupName(l.getGroupName());
vo.setName(l.getName());
vo.setLogoUrl(l.getLogoUrl());
vo.setLinkUrl(l.getLinkUrl());
vo.setSortNo(l.getSortNo());
vo.setStatus(l.getStatus());
return vo;
}
}
@@ -0,0 +1,39 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.entity.CmsPerson;
import lombok.Data;
import java.time.LocalDateTime;
/** 名师风采人物(docs/20 D-7) */
@Data
public class PersonVo {
private Integer id;
private Integer siteId;
private String name;
private String title;
private String bio;
private String avatar;
private Integer categoryId;
private Integer sortNo;
private Integer status;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public static PersonVo of(CmsPerson p) {
PersonVo vo = new PersonVo();
vo.setId(p.getId());
vo.setSiteId(p.getSiteId());
vo.setName(p.getName());
vo.setTitle(p.getTitle());
vo.setBio(p.getBio());
vo.setAvatar(p.getAvatar());
vo.setCategoryId(p.getCategoryId());
vo.setSortNo(p.getSortNo());
vo.setStatus(p.getStatus());
vo.setCreateTime(p.getCreateTime());
vo.setUpdateTime(p.getUpdateTime());
return vo;
}
}
@@ -0,0 +1,45 @@
package com.yunhai.cms.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 学校工作台聚合 VO(docs/09 §3.3,D6-4):CMS 不设学校表,
* 学校卡片 = 顶层站点(主站),院系子站经 parentSiteId 归属,schId/schName 冗余展示。
*/
@Data
public class SchoolVo {
/** 学校 ID(UserBase sys_group.id,主站冗余字段) */
private Integer schId;
private String schName;
/** 主站(顶层站点) */
private Integer mainSiteId;
/** 主站编码(学校上下文 URL 段 /sch/{code}) */
private String mainSiteCode;
/** 主站门户路径(W4:/portal/{mainSitePath}/,顶层=code) */
private String mainSitePath;
private String mainSiteName;
private String theme;
/** 该主站下站点总数(含主站自身) */
private Integer siteCount;
/** 该校全部站点文章总数(管理端规模口径,含未发布) */
private Long articleCount;
/** 主站状态:1 启用 / 0 停用 */
private Integer status;
/** UserBase 学校选项(「添加学校」选择器用) */
@Data
@NoArgsConstructor
@AllArgsConstructor
public static class Option {
private Integer id;
private String name;
}
}
@@ -0,0 +1,55 @@
package com.yunhai.cms.model.vo;
import lombok.Data;
/**
* 站点 VO。
*/
@Data
public class SiteVo {
private Integer id;
private String code;
/** 门户路径(W4:顶层=code;子站=父path/code,URL 唯一标识) */
private String path;
/** 上级站点ID(0=顶层校站点,《院系子站方案》) */
private Integer parentSiteId;
/** 上级站点名称(列表展示用,服务端装配) */
private String parentName;
private Integer schId;
private String schName;
private String name;
private String theme;
/** 站点配置 JSON 原文(domain/public_base_url/logo 等) */
private String configJson;
/** 0=停用 1=启用 */
private Integer status;
private String createTime;
public static SiteVo of(com.yunhai.cms.entity.CmsSite site) {
SiteVo vo = new SiteVo();
vo.setId(site.getId());
vo.setCode(site.getCode());
vo.setPath(site.getPath());
vo.setParentSiteId(site.getParentSiteId() == null ? 0 : site.getParentSiteId());
vo.setSchId(site.getSchId());
vo.setSchName(site.getSchName());
vo.setName(site.getName());
vo.setTheme(site.getTheme());
vo.setConfigJson(site.getConfigJson());
vo.setStatus(site.getStatus());
vo.setCreateTime(site.getCreateTime() == null ? null : site.getCreateTime().toString());
return vo;
}
}
@@ -0,0 +1,96 @@
package com.yunhai.cms.model.vo;
import com.yunhai.cms.theme.ThemeInfo;
import com.yunhai.cms.theme.ThemeParam;
import lombok.Data;
import java.util.List;
import java.util.Map;
/**
* 主题包 VO(管理端主题列表,方案 §5.2)。
*/
@Data
public class ThemeVo {
private String name;
private String title;
private String description;
private String version;
private String author;
private String bannerSize;
/** 预览图 URL(管理端资产代理 /api/theme/{name}/asset/screenshot.png,方案 §5.2) */
private String screenshotUrl;
/** 可调参数 schema(管理端表单按此动态生成,方案 §6.3) */
private List<ThemeParamVo> params;
/**
* 包级参数值(工作项 T):仅含被微调过、写在 .meta 覆盖层里的 key;
* 缺项由前端按 params[].defaultValue 回落展示(值不再存站点 config_json.theme_params)。
*/
private Map<String, String> paramValues;
/** 包来源(W1,G1):builtin=内置只读 / external=外部可管 */
private String source;
/** 被用站点数(W1,G1:删除保护口径一致) */
private long inUseCount;
/** 是否可编辑/删除(W1:仅外部包 true) */
private boolean editable;
/** 引用站点名列表(W1:删除保护提示/前端 tooltip,与 inUseCount 同源) */
private List<String> usedBySiteNames;
@Data
public static class ThemeParamVo {
private String key;
private String cssVar;
private String label;
private String type;
private String defaultValue;
private List<ThemeParam.Option> options;
public static ThemeParamVo of(ThemeParam p) {
ThemeParamVo vo = new ThemeParamVo();
vo.setKey(p.getKey());
vo.setCssVar(p.getCssVar());
vo.setLabel(p.getLabel());
vo.setType(p.getType());
vo.setDefaultValue(p.getDefaultValue());
vo.setOptions(p.getOptions());
return vo;
}
}
public static ThemeVo of(ThemeInfo info) {
ThemeVo vo = new ThemeVo();
vo.setName(info.getName());
vo.setTitle(info.getTitle());
vo.setDescription(info.getDescription());
vo.setVersion(info.getVersion());
vo.setAuthor(info.getAuthor());
vo.setBannerSize(info.getBannerSize());
vo.setScreenshotUrl("/api/theme/" + info.getName() + "/asset/screenshot.png");
vo.setParams(new java.util.ArrayList<>());
for (ThemeParam p : info.getParams()) {
vo.getParams().add(ThemeParamVo.of(p));
}
vo.setParamValues(info.getParamValues() == null
? new java.util.LinkedHashMap<>() : new java.util.LinkedHashMap<>(info.getParamValues()));
return vo;
}
}
@@ -0,0 +1,73 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsArticle;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
public interface CmsArticleRepository extends JpaRepository<CmsArticle, Integer>,
JpaSpecificationExecutor<CmsArticle> {
Optional<CmsArticle> findByIdAndSiteId(Integer id, Integer siteId);
long countByCategoryId(Integer categoryId);
/** 学校工作台规模计数(docs/09 §3.3,管理端口径含未发布) */
long countBySiteId(Integer siteId);
/** 栏目列表页:已发布,发布时间倒序(置顶由 Specification/排序组合处理) */
Page<CmsArticle> findBySiteIdAndCategoryIdAndStatus(Integer siteId, Integer categoryId,
Integer status, Pageable pageable);
/** 栏目聚合查询(D1-2/D1-3):父栏目区块/列表页聚合列表型子孙栏目的文章 */
Page<CmsArticle> findBySiteIdAndCategoryIdInAndStatus(Integer siteId, Collection<Integer> categoryIds,
Integer status, Pageable pageable);
/** 门户:首页按站点取已发布(置顶优先),条数由 Pageable 控制 */
List<CmsArticle> findBySiteIdAndStatus(Integer siteId, Integer status, Pageable pageable);
/**
* 详情页上下篇:publish_time 相同(如同秒批量导入)时以 id 决胜,
* 约定列表排序 = publish_time desc, id desc,上一篇文章是该序中的后继。
*/
@Query("select a from CmsArticle a where a.siteId = :siteId and a.status = :status"
+ " and (a.publishTime < :pt or (a.publishTime = :pt and a.id < :id))"
+ " order by a.publishTime desc, a.id desc")
List<CmsArticle> findPrevPublished(@Param("siteId") Integer siteId, @Param("status") Integer status,
@Param("pt") LocalDateTime pt, @Param("id") Integer id,
Pageable pageable);
@Query("select a from CmsArticle a where a.siteId = :siteId and a.status = :status"
+ " and (a.publishTime > :pt or (a.publishTime = :pt and a.id > :id))"
+ " order by a.publishTime asc, a.id asc")
List<CmsArticle> findNextPublished(@Param("siteId") Integer siteId, @Param("status") Integer status,
@Param("pt") LocalDateTime pt, @Param("id") Integer id,
Pageable pageable);
/** 站内搜索(title/summary LIKE,MVP 不上 ES,方案 §2) */
Page<CmsArticle> findBySiteIdAndStatusAndTitleContainingOrSiteIdAndStatusAndSummaryContaining(
Integer siteId, Integer status, String title, Integer siteId2, Integer status2, String summary,
Pageable pageable);
/** 定时发布扫描:草稿且发布时间已到 */
List<CmsArticle> findByStatusAndPublishTimeNotNullAndPublishTimeLessThanEqual(Integer status,
LocalDateTime now);
/** 定时下架扫描:已发布且下架时间已到 */
List<CmsArticle> findByStatusAndOfflineTimeNotNullAndOfflineTimeLessThanEqual(Integer status,
LocalDateTime now);
/** 浏览量异步累加(fire-and-forget,失败忽略,方案 §5.2) */
@Modifying
@Query("update CmsArticle a set a.viewCount = a.viewCount + 1 where a.id = :id")
int incrementViewCount(@Param("id") Integer id);
}
@@ -0,0 +1,41 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsAttachment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
import java.util.Optional;
public interface CmsAttachmentRepository extends JpaRepository<CmsAttachment, Integer> {
Page<CmsAttachment> findBySiteId(Integer siteId, Pageable pageable);
List<CmsAttachment> findByArticleId(Integer articleId);
/** 编辑器图片上传时先按路径去重绑定,避免重复行 */
long countBySiteIdAndFilePath(Integer siteId, String filePath);
/** 未使用附件(B2 语义:无文章引用且无场景引用) */
Page<CmsAttachment> findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId, Pageable pageable);
long countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(Integer siteId);
/** 按 webUrl 反查(B2 保存点引用回写;webUrl 全站唯一由 uuid32 文件名保证,查询仍带 siteId 防御) */
Optional<CmsAttachment> findBySiteIdAndWebUrl(Integer siteId, String webUrl);
List<CmsAttachment> findByRefTypeAndRefId(String refType, Integer refId);
@Modifying
@Query("update CmsAttachment a set a.articleId = :articleId where a.id in :ids")
int bindArticle(@Param("articleId") Integer articleId, @Param("ids") List<Integer> ids);
/** 文章删除:附件转孤儿 */
@Modifying
@Query("update CmsAttachment a set a.articleId = null where a.articleId = :articleId")
int unbindArticle(@Param("articleId") Integer articleId);
}
@@ -0,0 +1,17 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsBanner;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CmsBannerRepository extends JpaRepository<CmsBanner, Integer> {
/** 管理端:展示位全量(含停用,按排序) */
List<CmsBanner> findBySiteIdAndPositionOrderBySortNoAscIdAsc(Integer siteId, String position);
List<CmsBanner> findBySiteIdOrderByPositionAscSortNoAscIdAsc(Integer siteId);
/** 引用完整性(删除拦截用):指定跳转类型下是否存在指向目标 id 的轮播(跨站也拦,link_value 全局比对) */
boolean existsByLinkTypeAndLinkValue(Integer linkType, String linkValue);
}
@@ -0,0 +1,16 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsCategory;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CmsCategoryRepository extends JpaRepository<CmsCategory, Integer> {
/** 栏目树数据源(内存组树,站点数 ≤20 栏目量级小) */
List<CmsCategory> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
long countBySiteId(Integer siteId);
long countByParentId(Integer parentId);
}
@@ -0,0 +1,11 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsLink;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CmsLinkRepository extends JpaRepository<CmsLink, Integer> {
List<CmsLink> findBySiteIdOrderByGroupNameAscSortNoAscIdAsc(Integer siteId);
}
@@ -0,0 +1,16 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsPerson;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
/** 名师风采(docs/20 D-7) */
public interface CmsPersonRepository extends JpaRepository<CmsPerson, Integer> {
/** 管理端:站点全量(含停用,按排序) */
List<CmsPerson> findBySiteIdOrderBySortNoAscIdAsc(Integer siteId);
/** 门户:启用人物(可选栏目过滤),按排序 */
List<CmsPerson> findBySiteIdAndStatusOrderBySortNoAscIdAsc(Integer siteId, Integer status);
}
@@ -0,0 +1,54 @@
package com.yunhai.cms.repository;
import com.yunhai.cms.entity.CmsSite;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface CmsSiteRepository extends JpaRepository<CmsSite, Integer> {
/**
* 按站点编码取站点(严格版:命中多条时抛 IncorrectResultSizeDataAccessException)。
*
* <p>语义说明:{@code code} 自 W4 起不是全局唯一(唯一键为 {@code uk_site_parent_code(parent_site_id, code)}),
* 多校部署下不同父站可挂同名 code 子站。**生产代码请用**
* {@link #findFirstByCodeOrderByIdAsc(String)}(确定性取最早一条,不会因歧义 500);
* 本方法语义为"断言 code 唯一",保留给集成测试做唯一性/落库断言。
*/
Optional<CmsSite> findByCode(String code);
/**
* 按站点编码取站点(取 id 最小者)——生产路径口径。
*
* <p>歧义时不再抛异常导致 500,而是确定性取最早创建的站点;跨服务单键定位的正解仍是
* (siteCode,itemCode) 成对 + 建站保证 code 全局唯一(设计 §1.3/R4)。
*/
Optional<CmsSite> findFirstByCodeOrderByIdAsc(String code);
/** 门户路径精确查找(W4:/portal/{path} 身份解析唯一入口) */
Optional<CmsSite> findByPath(String path);
/** 路径冲突检查(新建/改挂重算用;编辑排除自身) */
boolean existsByPath(String path);
boolean existsByPathAndIdNot(String path, Integer id);
/** 皮肤包被用站点数(W1 删除保护共用) */
long countByTheme(String theme);
/** 使用指定皮肤包的站点(W1 删除保护:拒绝消息提示站点名) */
List<CmsSite> findByTheme(String theme);
/** 启用站点(B-5:显式按 id 升序,/portal 默认入口跳转目标确定) */
List<CmsSite> findByStatusOrderByIdAsc(Integer status);
/** 本校可管理的站点(非超管按 currentRole.schId 过滤) */
List<CmsSite> findBySchId(Integer schId);
/** 指定主站下的启用院系子站(门户"专业院系"下拉数据源,《院系子站方案》) */
List<CmsSite> findByParentSiteIdAndStatusOrderByIdAsc(Integer parentSiteId, Integer status);
/** 是否存在院系子站(站点改挂上级前的守卫) */
boolean existsByParentSiteId(Integer parentSiteId);
}
@@ -0,0 +1,21 @@
package com.yunhai.cms.service;
import com.yunhai.cms.event.ArticlePublishedEvent;
/**
* 微信图文推送服务接口(方案 §7.4 预留入口)。
*
* <p>本期 {@link com.yunhai.cms.service.impl.NoopWeChatPushService} 空实现:
* 事件照常发布,Noop 直接返回,零微信外联/零微信配置键/零新增依赖。
* 启用时按《微信平台对接方案.md》提供真实实现(公众号配置经 projman 注入)。
*/
public interface WeChatPushService {
/**
* 文章发布后回调(AFTER_COMMIT)。
*
* @param event 发布事件(articleId/siteId/title)
* @return 是否已推送(Noop 恒 false)
*/
boolean onArticlePublished(ArticlePublishedEvent event);
}
@@ -0,0 +1,35 @@
package com.yunhai.cms.service.impl;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
/**
* 定时发布/下架触发器(方案 §13 风险表:MVP 用 @Scheduled 每分钟扫到期记录,无重调度器)。
* 扫描语义在 {@link ArticleScheduleWorker}(D5-5 拆分:触发与逻辑分离,IT 直接调 Worker
* 消除自动调度与手动调用的并发竞态)。cms.schedule.auto-enabled=false 可关自动触发
* (仅集成测试用,生产默认开)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
@ConditionalOnProperty(value = "cms.schedule.auto-enabled", havingValue = "true", matchIfMissing = true)
public class ArticleScheduleTask {
private final ArticleScheduleWorker worker;
/** 定时发布:草稿且发布时间已到 */
@Scheduled(initialDelay = 30_000, fixedDelay = 60_000)
public void publishDue() {
worker.publishDue();
}
/** 定时下架:已发布且下架时间已到 */
@Scheduled(initialDelay = 45_000, fixedDelay = 60_000)
public void offlineDue() {
worker.offlineDue();
}
}
@@ -0,0 +1,71 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.repository.CmsArticleRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;
import java.time.LocalDateTime;
import java.util.List;
/**
* 定时发布/下架扫描逻辑(方案 §13 风险表:MVP 每分钟扫到期记录,无重调度器)。
* 从 ArticleScheduleTask 拆出纯逻辑体(D5-5):@Scheduled 触发器与扫描语义分离,
* 集成测试直接调 Worker 消除"上下文自动调度与测试手动调用并发处理同一记录"的竞态
* (表现为事件数翻倍断言失败)。每条记录独立事务;到期即发对应事件
* (缓存失效 + 微信挂点照常生效)。
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class ArticleScheduleWorker {
private final CmsArticleRepository articleRepository;
private final TransactionTemplate transactionTemplate;
private final ApplicationEventPublisher eventPublisher;
/** 定时发布:草稿且发布时间已到 */
public void publishDue() {
List<CmsArticle> dueList =
articleRepository.findByStatusAndPublishTimeNotNullAndPublishTimeLessThanEqual(
ArticleStatusEnum.DRAFT.getCode(), LocalDateTime.now());
for (CmsArticle due : dueList) {
transactionTemplate.executeWithoutResult(status -> {
CmsArticle article = articleRepository.findById(due.getId()).orElse(null);
if (article == null || article.getStatus() != ArticleStatusEnum.DRAFT.getCode()) {
return;
}
article.setStatus(ArticleStatusEnum.PUBLISHED.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(),
article.getSiteId(), article.getTitle()));
log.info("定时发布文章 articleId={} siteId={}", article.getId(), article.getSiteId());
});
}
}
/** 定时下架:已发布且下架时间已到 */
public void offlineDue() {
List<CmsArticle> dueList =
articleRepository.findByStatusAndOfflineTimeNotNullAndOfflineTimeLessThanEqual(
ArticleStatusEnum.PUBLISHED.getCode(), LocalDateTime.now());
for (CmsArticle due : dueList) {
transactionTemplate.executeWithoutResult(status -> {
CmsArticle article = articleRepository.findById(due.getId()).orElse(null);
if (article == null || article.getStatus() != ArticleStatusEnum.PUBLISHED.getCode()) {
return;
}
article.setStatus(ArticleStatusEnum.OFFLINE.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), article.getSiteId()));
log.info("定时下架文章 articleId={} siteId={}", article.getId(), article.getSiteId());
});
}
}
}
@@ -0,0 +1,237 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsArticle;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.ArticleStatusEnum;
import com.yunhai.cms.event.ArticleChangedEvent;
import com.yunhai.cms.event.ArticlePublishedEvent;
import com.yunhai.cms.model.dto.ArticleSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.query.ArticlePageQuery;
import com.yunhai.cms.model.vo.ArticleEditVo;
import com.yunhai.cms.model.vo.ArticlePageItem;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsAttachmentRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.utils.HtmlSanitizer;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import javax.persistence.criteria.Predicate;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 文章管理(方案 §9.1/§9.2):草稿/发布/下架/置顶/定时发布下架 + jsoup 保存时清洗。
* 发布动作发布 {@link ArticlePublishedEvent}(AFTER_COMMIT:缓存失效 + 微信 Noop 挂点)。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ArticleService {
private final CmsArticleRepository articleRepository;
private final CmsCategoryRepository categoryRepository;
private final CmsAttachmentRepository attachmentRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
/** 文章分页(管理端,全状态可见);pageSize 上限 100 防全表拖库(体检 H-6) */
public Map<String, Object> page(BoaUser user, ArticlePageQuery query) {
siteService.requireManageable(user, query.getSiteId());
int pageIndex = query.getPage() == null || query.getPage() < 1 ? 0 : query.getPage() - 1;
int pageSize = query.getPageSize() == null || query.getPageSize() < 1 ? 10 : query.getPageSize();
pageSize = Math.min(pageSize, 100);
Pageable pageable = PageRequest.of(pageIndex, pageSize,
Sort.by(Sort.Order.desc("topmost"), Sort.Order.desc("createTime")));
Specification<CmsArticle> spec = (root, cq, cb) -> {
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(root.get("siteId"), query.getSiteId()));
if (query.getCategoryIds() != null && !query.getCategoryIds().isEmpty()) {
predicates.add(root.get("categoryId").in(query.getCategoryIds()));
}
if (query.getStatus() != null) {
predicates.add(cb.equal(root.get("status"), query.getStatus()));
}
if (StringUtils.hasText(query.getKeyword())) {
predicates.add(cb.like(root.get("title"), "%" + query.getKeyword().trim() + "%"));
}
return cb.and(predicates.toArray(new Predicate[0]));
};
Map<Integer, String> categoryNames = categoryNameMap(query.getSiteId());
Page<CmsArticle> page = articleRepository.findAll(spec, pageable);
List<ArticlePageItem> items = new ArrayList<>();
for (CmsArticle article : page.getContent()) {
items.add(ArticlePageItem.of(article, categoryNames.get(article.getCategoryId())));
}
Map<String, Object> result = new HashMap<>();
result.put("total", page.getTotalElements());
result.put("list", items);
return result;
}
private Map<Integer, String> categoryNameMap(Integer siteId) {
Map<Integer, String> names = new HashMap<>();
for (CmsCategory c : categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId)) {
names.put(c.getId(), c.getName());
}
return names;
}
/** 编辑回显 */
public ArticleEditVo getEdit(BoaUser user, Integer siteId, Integer id) {
siteService.requireManageable(user, siteId);
CmsArticle article = articleRepository.findByIdAndSiteId(id, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
return ArticleEditVo.of(article);
}
/**
* 保存(草稿)。content 过 jsoup 白名单;摘要空则截取正文;
* 请求携带 attachmentIds 时回填编辑器图片归属。已发布文章被修改后发变更事件保持门户新鲜。
*/
@Transactional
public Integer save(BoaUser user, ArticleSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsCategory category = categoryRepository.findById(req.getCategoryId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "所属栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目不属于该站点");
}
CmsArticle article;
if (req.getId() == null) {
article = new CmsArticle();
article.setSiteId(site.getId());
article.setStatus(ArticleStatusEnum.DRAFT.getCode());
} else {
article = articleRepository.findByIdAndSiteId(req.getId(), site.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
}
validateTimeWindow(req.getPublishTime(), req.getOfflineTime());
article.setCategoryId(category.getId());
article.setTitle(req.getTitle());
article.setContent(HtmlSanitizer.clean(req.getContent()));
String summary = req.getSummary();
if (!StringUtils.hasText(summary)) {
summary = HtmlSanitizer.toText(article.getContent(), 120);
}
article.setSummary(summary);
article.setCoverImage(req.getCoverImage());
article.setAuthorName(req.getAuthorName());
article.setSource(req.getSource());
article.setTopmost(req.getTopmost() == null ? 0 : req.getTopmost());
article.setPublishTime(req.getPublishTime());
article.setOfflineTime(req.getOfflineTime());
article = articleRepository.save(article);
if (req.getAttachmentIds() != null && !req.getAttachmentIds().isEmpty()) {
attachmentRepository.bindArticle(article.getId(), req.getAttachmentIds());
}
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
return article.getId();
}
/**
* 发布:填发布时间与作者。publishTime 为空或已过 → 立即发布;
* 未来时间 → 保持草稿(定时发布,@Scheduled 扫描触发)。
*/
@Transactional
public void publish(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
LocalDateTime now = LocalDateTime.now();
LocalDateTime effective = article.getPublishTime() != null && article.getPublishTime().isAfter(now)
? article.getPublishTime() : now;
// 重新发布即开启新生命周期:过期的定时下架时间若不清掉,发布后会被调度器立刻下架
// (旧数据/边界操作产生的矛盾态在此自愈,save 入口已拦截新产生的矛盾时间窗)
if (article.getOfflineTime() != null && !article.getOfflineTime().isAfter(effective)) {
article.setOfflineTime(null);
}
article.setPublishTime(effective);
if (!StringUtils.hasText(article.getAuthorAccount()) && user != null) {
article.setAuthorAccount(user.getAccount());
if (!StringUtils.hasText(article.getAuthorName())) {
article.setAuthorName(user.getName());
}
}
if (effective.isAfter(now)) {
article.setStatus(ArticleStatusEnum.DRAFT.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
} else {
article.setStatus(ArticleStatusEnum.PUBLISHED.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticlePublishedEvent(article.getId(), site.getId(), article.getTitle()));
}
}
/** 下架 */
@Transactional
public void offline(BoaUser user, Integer siteId, Integer id) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
article.setStatus(ArticleStatusEnum.OFFLINE.getCode());
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/** 置顶开关 */
@Transactional
public void topmost(BoaUser user, Integer siteId, Integer id, boolean enable) {
CmsSite site = siteService.requireManageable(user, siteId);
CmsArticle article = requireArticle(site.getId(), id);
article.setTopmost(enable ? 1 : 0);
articleRepository.save(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/**
* 删除:附件转孤儿(article_id 置空,由附件管理页清理,方案 §9.1 附件管理)。
* 轮播仍引用该文章时拦截删除(引用完整性统一规则:被引用即拦,与栏目删除一致)。
*/
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsArticle article = requireArticle(site.getId(), req.getId());
if (bannerRepository.existsByLinkTypeAndLinkValue(
com.yunhai.cms.enums.LinkTypeEnum.ARTICLE.getCode(), String.valueOf(article.getId()))) {
throw new BizException(ErrorCode.PARAM_ERROR,
"存在轮播图跳转到该文章,请先在轮播管理中删除或修改对应轮播");
}
attachmentRepository.unbindArticle(article.getId());
articleRepository.delete(article);
eventPublisher.publishEvent(new ArticleChangedEvent(article.getId(), site.getId()));
}
/** 定时时间窗校验:offlineTime 必须晚于 publishTime(否则调度器会发布后立刻下架,状态机自相矛盾) */
private void validateTimeWindow(LocalDateTime publishTime, LocalDateTime offlineTime) {
if (publishTime != null && offlineTime != null && !offlineTime.isAfter(publishTime)) {
throw new BizException(ErrorCode.PARAM_ERROR, "定时下架时间必须晚于发布时间");
}
}
private CmsArticle requireArticle(Integer siteId, Integer id) {
return articleRepository.findByIdAndSiteId(id, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "文章不存在"));
}
}
@@ -0,0 +1,343 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsAttachment;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.AttachmentVo;
import com.yunhai.cms.repository.CmsAttachmentRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import javax.imageio.ImageIO;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 附件上传/管理(方案 §7.3):FILE_PATH + FILE_WEB_PATH 双 key 约定。
* 存储 {FILE_PATH}/cms/{siteCode}/{yyyyMM}/{uuid32}.{ext};类型白名单 + 重命名存储,
* 禁止可执行后缀(方案 §8.5);图片类叠加 Content-Type + 魔数双校验(A-3)。
* 轮播图/友链 Logo 走同一接口。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AttachmentService {
private static final DateTimeFormatter MONTH_DIR = DateTimeFormatter.ofPattern("yyyyMM");
/** 富文本资产地址(img src / a href,单双引号皆可) */
private static final Pattern ASSET_URL_PATTERN =
Pattern.compile("(?:src|href)\\s*=\\s*[\"']([^\"']+)[\"']", Pattern.CASE_INSENSITIVE);
/** 上传白名单(图片/文档/压缩包/音视频);svg 因可携带脚本不入白名单(XSS) */
private static final List<String> ALLOWED_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico",
"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md",
"zip", "rar", "7z", "mp4", "mp3", "wav");
/** 图片类扩展名:在白名单之上叠加 Content-Type + 魔数双校验,防伪装后缀(A-3) */
private static final List<String> IMAGE_EXTS = Arrays.asList(
"jpg", "jpeg", "png", "gif", "webp", "bmp", "ico");
/**
* 上传 scene 白名单(B2 附件引用模型):banner=轮播 / link=友链 / site=站点配置 /
* category=单页栏目贴图 / person=名师头像(docs/20 D-7)。svc_item/svc_item_asset 两个事项场景
* 已随事项库迁出办事大厅(V2.0,见 docs/19 §九),CMS 侧下线。
*/
private static final List<String> ALLOWED_SCENES =
Arrays.asList("banner", "link", "site", "category", "person");
private final CmsAttachmentRepository attachmentRepository;
private final SiteService siteService;
@Value("${FILE_PATH:./upload/}")
private String filePath;
/** 只接受相对路径(/cms/upload/)或公网可达绝对地址;绝对内网地址(localhost:8072 等)入库即断图(B-1/C-5) */
@Value("${FILE_WEB_PATH:/cms/upload/}")
private String fileWebPath;
/** 上传(编辑器图片/附件/轮播图/Logo 复用);articleId 可空;scene 可空(banner/link/site/category) */
@Transactional
public AttachmentVo upload(BoaUser user, Integer siteId, Integer articleId, String scene, MultipartFile file) {
CmsSite site = siteService.requireManageable(user, siteId);
if (StringUtils.hasText(scene) && !ALLOWED_SCENES.contains(scene)) {
throw new BizException(ErrorCode.PARAM_ERROR,
"scene 非法:" + scene + "(仅 banner/link/site/category)");
}
if (file == null || file.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "上传文件不能为空");
}
String originalName = file.getOriginalFilename() == null ? "file" : file.getOriginalFilename();
String ext = extOf(originalName);
if (!ALLOWED_EXTS.contains(ext)) {
throw new BizException(ErrorCode.PARAM_ERROR, "不支持的文件类型:" + ext);
}
if (IMAGE_EXTS.contains(ext)) {
requireRealImage(file, ext);
}
String relativeDir = "cms/" + site.getCode() + "/" + MONTH_DIR.format(LocalDateTime.now());
String storedName = UUID.randomUUID().toString().replace("-", "") + "." + ext;
File dir = new File(filePath, relativeDir);
if (!dir.exists() && !dir.mkdirs()) {
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "上传目录创建失败");
}
try {
file.transferTo(new File(dir, storedName));
} catch (IOException e) {
log.error("附件保存失败 siteId={}", siteId, e);
throw new BizException(ErrorCode.INTERNAL_SERVER_ERROR, "文件保存失败");
}
String relativePath = relativeDir + "/" + storedName;
String webUrl = joinUrl(fileWebPath, relativePath);
CmsAttachment attachment = new CmsAttachment();
attachment.setSiteId(site.getId());
attachment.setArticleId(articleId);
attachment.setRefType(StringUtils.hasText(scene) ? scene : null);
attachment.setFileName(originalName);
attachment.setFilePath(relativePath);
attachment.setWebUrl(webUrl);
attachment.setFileSize(file.getSize());
attachment.setMimeType(file.getContentType());
attachment = attachmentRepository.save(attachment);
return AttachmentVo.of(attachment);
}
/** 附件列表(unusedOnly=true 时仅未使用:无文章引用且无场景引用,附件管理页清理用) */
public Map<String, Object> page(BoaUser user, Integer siteId, boolean orphanOnly,
Integer page, Integer pageSize) {
siteService.requireManageable(user, siteId);
int pageIndex = page == null || page < 1 ? 0 : page - 1;
int size = pageSize == null || pageSize < 1 ? 10 : pageSize;
Page<CmsAttachment> result = orphanOnly
? attachmentRepository.findBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId,
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")))
: attachmentRepository.findBySiteId(siteId,
PageRequest.of(pageIndex, size, Sort.by(Sort.Direction.DESC, "createTime")));
List<AttachmentVo> list = new ArrayList<>();
for (CmsAttachment a : result.getContent()) {
list.add(AttachmentVo.of(a));
}
Map<String, Object> map = new HashMap<>();
map.put("total", result.getTotalElements());
map.put("list", list);
map.put("unusedCount", attachmentRepository.countBySiteIdAndArticleIdIsNullAndRefTypeIsNull(siteId));
return map;
}
/**
* 场景引用回写(B2):各保存点按 webUrl 反查并标记(webUrl 全站唯一由 uuid32 文件名保证)。
* 外链地址/找不到记录时静默跳过;幂等(重复标记同一引用无副作用)。
*/
@Transactional
public void markUsed(Integer siteId, String webUrl, String refType, Integer refId) {
if (!StringUtils.hasText(webUrl)) {
return;
}
attachmentRepository.findBySiteIdAndWebUrl(siteId, webUrl).ifPresent(a -> {
a.setRefType(refType);
a.setRefId(refId);
attachmentRepository.save(a);
});
}
/**
* 把任意上传地址归一为库内 webUrl 形态(相对 FILE_WEB_PATH 起始段)。
*
* <p>兼容相对地址({@code /cms/upload/..})与带站点/网关前缀的绝对地址;非本站上传地址返回空
* (外链、data: 等一律不参与场景引用)。
*/
public Optional<String> localWebUrl(String url) {
if (!StringUtils.hasText(url) || !StringUtils.hasText(fileWebPath)) {
return Optional.empty();
}
int start = url.indexOf(fileWebPath);
return start < 0 ? Optional.empty() : Optional.of(url.substring(start));
}
/**
* 抽出 HTML 中指向本站上传目录的资产地址(img src / a href),已归一为 webUrl。
*
* <p>用途:正文内嵌图这类"没有 id 列表可依赖"的引用,保存时以内容为权威口径重建归属。
*/
public Set<String> localAssetUrls(String html) {
Set<String> webUrls = new LinkedHashSet<>();
if (!StringUtils.hasText(html)) {
return webUrls;
}
Matcher matcher = ASSET_URL_PATTERN.matcher(html);
while (matcher.find()) {
localWebUrl(matcher.group(1)).ifPresent(webUrls::add);
}
return webUrls;
}
/**
* 内容派生引用的全量同步(事项库 R2):把 (refType, refId) 下的引用对齐到给定 webUrl 集合——
* 集合内标记归属、集合外解除归属(转回未使用),幂等。
*
* <p>为什么需要"解除"这一步:封面/正文图在编辑时可能被删掉或换掉,若不按内容同步,
* 旧资产的引用会永久挂在本事项上(既不被内容引用、也不进入未使用清理口径)。
*/
@Transactional
public int syncAssets(Integer siteId, String refType, Integer refId, Set<String> webUrls) {
Set<String> desired = webUrls == null ? Collections.emptySet() : webUrls;
List<CmsAttachment> stale = new ArrayList<>();
for (CmsAttachment a : attachmentRepository.findByRefTypeAndRefId(refType, refId)) {
if (!desired.contains(a.getWebUrl())) {
a.setRefType(null);
a.setRefId(null);
stale.add(a);
}
}
if (!stale.isEmpty()) {
attachmentRepository.saveAll(stale);
}
for (String webUrl : desired) {
markUsed(siteId, webUrl, refType, refId);
}
return desired.size();
}
/** 场景引用解除(B2):对象删除/换图时按引用清空;幂等(无匹配记录为 no-op) */
@Transactional
public void release(String refType, Integer refId) {
List<CmsAttachment> refs = attachmentRepository.findByRefTypeAndRefId(refType, refId);
for (CmsAttachment a : refs) {
a.setRefType(null);
a.setRefId(null);
}
attachmentRepository.saveAll(refs);
}
/**
* 站点配置引用回写(B2):config_json 中 logo/favicon 指向本地上传路径(FILE_WEB_PATH 前缀)时
* 标记 ref_type=site。先整体解引用再回填当前值,天然覆盖换图/清空两种编辑路径。
*/
@Transactional
public void markSiteConfigUsed(Integer siteId, Map<String, Object> config) {
release("site", siteId);
if (config == null || !StringUtils.hasText(fileWebPath)) {
return;
}
for (String key : new String[]{"logo", "favicon"}) {
Object value = config.get(key);
if (value instanceof String && ((String) value).startsWith(fileWebPath)) {
markUsed(siteId, (String) value, "site", siteId);
}
}
}
/** 删除附件记录(不删物理文件,运维定期清理磁盘,避免误删被引用文件) */
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
siteService.requireManageable(user, req.getSiteId());
CmsAttachment attachment = attachmentRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "附件不存在"));
if (!attachment.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该附件");
}
attachmentRepository.delete(attachment);
}
/**
* 图片类双校验(A-3):Content-Type 必须 image/*,且内容必须真实可解。
* 防 .exe 改名 .jpg 之类伪装后缀入库后经门户以图片身份对外分发。
*/
private void requireRealImage(MultipartFile file, String ext) {
String contentType = file.getContentType();
if (contentType == null || !contentType.toLowerCase(Locale.ROOT).startsWith("image/")) {
throw new BizException(ErrorCode.PARAM_ERROR, "图片文件类型非法(Content-Type=" + contentType + ")");
}
if (!isDecodableImage(file, ext)) {
throw new BizException(ErrorCode.PARAM_ERROR, "文件内容不是有效图片(疑似伪装后缀):" + file.getOriginalFilename());
}
}
/**
* 魔数校验:jpg/jpeg/png/gif/bmp 用 ImageIO 真实解码(可读才算图片);
* webp/ico JDK8 无内置 reader,退化为魔数头校验(RIFF....WEBP / 00 00 01 00)。
*/
private boolean isDecodableImage(MultipartFile file, String ext) {
if ("webp".equals(ext) || "ico".equals(ext)) {
byte[] head = readHead(file, 12);
if (head.length < 4) {
return false;
}
if ("webp".equals(ext)) {
return head.length >= 12
&& head[0] == 'R' && head[1] == 'I' && head[2] == 'F' && head[3] == 'F'
&& head[8] == 'W' && head[9] == 'E' && head[10] == 'B' && head[11] == 'P';
}
return head[0] == 0 && head[1] == 0 && head[2] == 1 && head[3] == 0;
}
try (InputStream in = file.getInputStream()) {
return ImageIO.read(in) != null;
} catch (IOException e) {
log.warn("图片魔数校验读取失败:{}", file.getOriginalFilename());
return false;
}
}
private byte[] readHead(MultipartFile file, int length) {
byte[] head = new byte[length];
try (InputStream in = file.getInputStream()) {
int read = 0;
while (read < length) {
int n = in.read(head, read, length - read);
if (n < 0) {
break;
}
read += n;
}
return Arrays.copyOf(head, read);
} catch (IOException e) {
return new byte[0];
}
}
private String extOf(String name) {
int dot = name.lastIndexOf('.');
if (dot < 0 || dot == name.length() - 1) {
return "";
}
return name.substring(dot + 1).toLowerCase(Locale.ROOT);
}
private String joinUrl(String base, String relative) {
if (!StringUtils.hasText(base)) {
return "/" + relative;
}
return base.endsWith("/") ? base + relative : base + "/" + relative;
}
}
@@ -0,0 +1,141 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsBanner;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.LinkTypeEnum;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.BannerSaveRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.BannerVo;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsBannerRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.List;
/**
* 轮播/推荐位管理(方案 §9.1 轮播管理)。保存时校验 link_type/link_value 匹配。
*/
@Service
@RequiredArgsConstructor
public class BannerService {
private final CmsBannerRepository bannerRepository;
private final CmsArticleRepository articleRepository;
private final CmsCategoryRepository categoryRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
private final AttachmentService attachmentService;
public List<BannerVo> list(BoaUser user, Integer siteId, String position) {
siteService.requireManageable(user, siteId);
List<CmsBanner> banners = StringUtils.hasText(position)
? bannerRepository.findBySiteIdAndPositionOrderBySortNoAscIdAsc(siteId, position)
: bannerRepository.findBySiteIdOrderByPositionAscSortNoAscIdAsc(siteId);
List<BannerVo> result = new ArrayList<>();
for (CmsBanner b : banners) {
result.add(BannerVo.of(b));
}
return result;
}
@Transactional
public BannerVo save(BoaUser user, BannerSaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
LinkTypeEnum linkType = LinkTypeEnum.ofCode(req.getLinkType());
if (linkType == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转类型非法:" + req.getLinkType() + "(仅 1=文章 2=栏目 3=外链)");
}
validateLink(site.getId(), linkType, req.getLinkValue());
CmsBanner banner;
if (req.getId() == null) {
banner = new CmsBanner();
banner.setSiteId(site.getId());
} else {
banner = bannerRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在"));
if (!banner.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该轮播");
}
}
banner.setPosition(StringUtils.hasText(req.getPosition()) ? req.getPosition() : "home");
banner.setTitle(req.getTitle());
banner.setImageUrl(req.getImageUrl());
banner.setLinkType(linkType.getCode());
banner.setLinkValue(req.getLinkValue());
banner.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
banner.setStartTime(req.getStartTime());
banner.setEndTime(req.getEndTime());
if (banner.getStartTime() != null && banner.getEndTime() != null
&& banner.getEndTime().isBefore(banner.getStartTime())) {
throw new BizException(ErrorCode.PARAM_ERROR, "下线时间不能早于上线时间");
}
banner.setStatus(req.getStatus() == null ? 1 : req.getStatus());
// docs/20 D-3/D-6:topic/event 位扩展字段透传(其余位一并落库,渲染端按位消费)
banner.setDescription(req.getDescription());
banner.setCtaText(req.getCtaText());
banner.setCtaUrl(req.getCtaUrl());
banner.setEventDate(req.getEventDate());
banner.setLocation(req.getLocation());
banner = bannerRepository.save(banner);
// B2 附件引用:先解引用再回写新图(编辑换图时旧图回未使用);幂等
attachmentService.release("banner", banner.getId());
attachmentService.markUsed(site.getId(), req.getImageUrl(), "banner", banner.getId());
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
return BannerVo.of(banner);
}
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsBanner banner = bannerRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "轮播不存在"));
if (!banner.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该轮播");
}
bannerRepository.delete(banner);
attachmentService.release("banner", banner.getId());
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "banner"));
}
/**
* link_type/link_value 匹配校验:1=文章 2=栏目(须存在且属于本站,禁止跨站引用)
* 3=外链(必须 http/https 开头)。
*/
private void validateLink(Integer siteId, LinkTypeEnum linkType, String linkValue) {
if (linkType == LinkTypeEnum.EXTERNAL) {
if (!StringUtils.hasText(linkValue)
|| !(linkValue.startsWith("http://") || linkValue.startsWith("https://"))) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链地址必须以 http(s):// 开头");
}
return;
}
if (!StringUtils.hasText(linkValue)) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标不能为空");
}
Integer targetId;
try {
targetId = Integer.valueOf(linkValue.trim());
} catch (NumberFormatException e) {
throw new BizException(ErrorCode.PARAM_ERROR, "跳转目标必须是数字 ID");
}
if (linkType == LinkTypeEnum.ARTICLE) {
articleRepository.findByIdAndSiteId(targetId, siteId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转文章不存在"));
} else if (linkType == LinkTypeEnum.CATEGORY) {
categoryRepository.findById(targetId)
.filter(c -> c.getSiteId().equals(siteId))
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "跳转栏目不存在"));
}
}
}
@@ -0,0 +1,206 @@
package com.yunhai.cms.service.impl;
import com.yunhai.cms.entity.CmsCategory;
import com.yunhai.cms.entity.CmsSite;
import com.yunhai.cms.enums.CategoryTypeEnum;
import com.yunhai.cms.event.SiteContentChangedEvent;
import com.yunhai.cms.model.dto.CategorySaveRequest;
import com.yunhai.cms.model.dto.CategorySortRequest;
import com.yunhai.cms.model.dto.DeleteRequest;
import com.yunhai.cms.model.vo.CategoryTreeNode;
import com.yunhai.cms.repository.CmsArticleRepository;
import com.yunhai.cms.repository.CmsCategoryRepository;
import com.yunhai.cms.utils.HtmlSanitizer;
import com.yunhai.platform.apistd.BizException;
import com.yunhai.platform.utils.exception.ErrorCode;
import com.yunhai.platform.utils.model.BoaUser;
import lombok.RequiredArgsConstructor;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 栏目树管理(方案 §9.1 栏目管理)。删除校验空栏目(无子栏目且无文章)。
*/
@Service
@RequiredArgsConstructor
public class CategoryService {
/** 首页展示形态白名单(docs/11;空=主题默认卡) */
private static final java.util.Set<String> HOME_STYLE_VALUES =
new java.util.HashSet<>(java.util.Arrays.asList("news_grid", "media_focus"));
/** 祖先链上溯上限(防脏数据自环导致死循环;正常栏目树远低于此深度) */
private static final int MAX_ANCESTOR_WALK = 64;
private final CmsCategoryRepository categoryRepository;
private final CmsArticleRepository articleRepository;
private final com.yunhai.cms.repository.CmsBannerRepository bannerRepository;
private final SiteService siteService;
private final ApplicationEventPublisher eventPublisher;
/** 栏目树(管理端全量含隐藏;门户侧由 PortalQueryService 另行过滤) */
public List<CategoryTreeNode> tree(BoaUser user, Integer siteId) {
siteService.requireManageable(user, siteId);
List<CmsCategory> all = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(siteId);
Map<Integer, CategoryTreeNode> nodeMap = new HashMap<>();
for (CmsCategory c : all) {
nodeMap.put(c.getId(), CategoryTreeNode.of(c));
}
List<CategoryTreeNode> roots = new ArrayList<>();
for (CmsCategory c : all) {
CategoryTreeNode node = nodeMap.get(c.getId());
CategoryTreeNode parent = c.getParentId() == null ? null : nodeMap.get(c.getParentId());
if (parent == null) {
roots.add(node);
} else {
parent.getChildren().add(node);
}
}
return roots;
}
@Transactional
public CategoryTreeNode save(BoaUser user, CategorySaveRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CategoryTypeEnum type = CategoryTypeEnum.ofCode(req.getType());
if (type == null) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目类型非法:" + req.getType() + "(仅 1=列表 2=单页 3=外链)");
}
if (type == CategoryTypeEnum.LINK && !StringUtils.hasText(req.getLinkUrl())) {
throw new BizException(ErrorCode.PARAM_ERROR, "外链栏目必须填写外链地址");
}
if (StringUtils.hasText(req.getHomeStyle()) && !HOME_STYLE_VALUES.contains(req.getHomeStyle())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"首页展示形态非法:" + req.getHomeStyle() + "(仅支持 news_grid / media_focus)");
} CmsCategory category;
if (req.getId() == null) {
category = new CmsCategory();
category.setSiteId(site.getId());
} else {
category = categoryRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权修改该栏目");
}
}
validateParent(category, req);
category.setParentId(req.getParentId() == null ? 0 : req.getParentId());
category.setName(req.getName());
category.setType(type.getCode());
category.setLinkUrl(req.getLinkUrl());
category.setTemplate(req.getTemplate());
// F3(docs/20 §四):「首页展示形态」唯一入口收敛到首页布局编辑器,栏目管理不再编辑该字段——
// 入参缺省时保留已有值(存量数据不清除,无布局站点的回落渲染仍消费);显式传值仍校验(API 兼容)
category.setHomeStyle(StringUtils.hasText(req.getHomeStyle()) ? req.getHomeStyle() : category.getHomeStyle());
category.setContent(HtmlSanitizer.clean(req.getContent()));
category.setSortNo(req.getSortNo() == null ? 0 : req.getSortNo());
category.setSeoTitle(req.getSeoTitle());
category.setSeoDescription(req.getSeoDescription());
category.setStatus(req.getStatus() == null ? 1 : req.getStatus());
category = categoryRepository.save(category);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
return CategoryTreeNode.of(category);
}
private void validateParent(CmsCategory category, CategorySaveRequest req) {
Integer parentId = req.getParentId() == null ? 0 : req.getParentId();
if (parentId == 0) {
return;
}
if (category.getId() != null && parentId.equals(category.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不能是自身");
}
CmsCategory parent = categoryRepository.findById(parentId)
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "父栏目不存在"));
if (!parent.getSiteId().equals(req.getSiteId())) {
throw new BizException(ErrorCode.PARAM_ERROR, "父栏目不属于该站点");
}
requireNoCycle(category, parent);
}
/**
* 防环:从目标父栏目沿<b>祖先链</b>上溯,遇到"正在保存的栏目自身"即为环。
*
* <p>原实现只挡一级父子互换(parent.parentId == 自己),三级及以上的环写得进去;
* 环会让整枝栏目在门户导航与栏目树上消失,故补成任意层级(前端下拉同步放开置灰,
* 改为保存前同口径校验 + 明确提示,前后端口径一致)。
*/
private void requireNoCycle(CmsCategory category, CmsCategory parent) {
if (category.getId() == null) {
return;
}
int guard = 0;
CmsCategory cursor = parent;
while (cursor != null && guard++ <= MAX_ANCESTOR_WALK) {
if (category.getId().equals(cursor.getId())) {
throw new BizException(ErrorCode.PARAM_ERROR,
"不支持把栏目挂到自身或自己的子栏目下(会成环)");
}
Integer next = cursor.getParentId();
cursor = (next == null || next == 0) ? null : categoryRepository.findById(next).orElse(null);
}
}
/** 删除(校验空栏目 + 轮播引用,统一"被引用即拦"规则) */
@Transactional
public void delete(BoaUser user, DeleteRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
CmsCategory category = categoryRepository.findById(req.getId())
.orElseThrow(() -> new BizException(ErrorCode.PARAM_ERROR, "栏目不存在"));
if (!category.getSiteId().equals(site.getId())) {
throw new BizException(ErrorCode.FORBIDDEN, "无权删除该栏目");
}
if (categoryRepository.countByParentId(category.getId()) > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "存在子栏目,请先删除子栏目");
}
if (articleRepository.countByCategoryId(category.getId()) > 0) {
throw new BizException(ErrorCode.PARAM_ERROR, "栏目下存在文章,请先处理文章");
}
if (bannerRepository.existsByLinkTypeAndLinkValue(
com.yunhai.cms.enums.LinkTypeEnum.CATEGORY.getCode(), String.valueOf(category.getId()))) {
throw new BizException(ErrorCode.PARAM_ERROR,
"存在轮播图跳转到该栏目,请先在轮播管理中删除或修改对应轮播");
}
categoryRepository.delete(category);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
}
/**
* 子栏目排序(UserBase SortChildren 同语义):ids 顺序即 sortNo 1..n。
*/
@Transactional
public void sort(BoaUser user, CategorySortRequest req) {
CmsSite site = siteService.requireManageable(user, req.getSiteId());
List<Integer> ids = req.getIds();
if (ids.isEmpty()) {
throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表不能为空");
}
if (ids.stream().distinct().count() != ids.size()) {
throw new BizException(ErrorCode.PARAM_ERROR, "子栏目ID列表存在重复");
}
List<CmsCategory> children = categoryRepository.findBySiteIdOrderBySortNoAscIdAsc(site.getId()).stream()
.filter(c -> c.getParentId() != null && c.getParentId().equals(req.getParentId()))
.collect(Collectors.toList());
Map<Integer, CmsCategory> childMap = children.stream()
.collect(Collectors.toMap(CmsCategory::getId, c -> c, (a, b) -> a));
for (Integer id : ids) {
if (!childMap.containsKey(id)) {
throw new BizException(ErrorCode.PARAM_ERROR, "ids 中存在不属于该栏目的直接子栏目");
}
}
// 按传入顺序落库排序(1 起)
for (int i = 0; i < ids.size(); i++) {
childMap.get(ids.get(i)).setSortNo(i + 1);
}
categoryRepository.saveAll(children);
eventPublisher.publishEvent(new SiteContentChangedEvent(site.getId(), "category"));
}
}

Some files were not shown because too many files have changed in this diff Show More