# wujies-cms-plugin **Repository Path**: wujies/wujies-cms-plugin ## Basic Information - **Project Name**: wujies-cms-plugin - **Description**: 无界平台的建站插件可以建自己的官网 - **Primary Language**: Unknown - **License**: Not specified - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 0 - **Forks**: 0 - **Created**: 2026-11-01 - **Last Updated**: 2026-11-03 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README

Wujies 无界 logo

内容中心,后台用 Vue 远程应用管内容,公开页用 Thymeleaf 服务端直出:主题 = 产物里的一个目录。

应用版本 应用编码 License
官网 插件开发 打包应用 Gitee

--- ## 这个应用是什么 Wujies 无界平台([wujies.cn](https://wujies.cn))的**内容应用**,Halo 的玩法:后台是 Vue 远程应用, 公开页是**服务端渲染的整页 HTML**,主题只是产物里的一个目录。默认主题(`themes/default/`)是 Nike 官网的版式骨架。 - **后台**:应用市场装上后多一个「内容中心」菜单,前端是远程应用(`frontend/`,27 个文件), 管文章、分类、标签、独立页面、菜单与站点配置的列表与编辑器。 - **前台**:`/site/**` 整页由本插件直出(首页 / 文章 / 分类列表 / 独立页面 / 商城页 / 商品详情 / 404), 模板与主题 CSS、JS 都在本仓库产物里(见「公开页与自带样式」)。 - **它也是整个前台的骨架**:主题令牌、共享件(`.pager` / `.row-list` / `.shop__list` …)都在这里, 各应用自己那一屏的样式经 `appStyles` 这个唯一的口子插进来 —— 图床、商城、报价、社交四个插件仓的 README 现在都引用本文「写主题时必须守住的五条」第 5 条。 应用编码 `cms` | 应用名称 内容中心 | 应用版本 `1.0.0` | 后端构件 `wujies-cms-plugin` | Java 源码 64 个文件 总体设计与分阶段计划在**项目根**的 `wujies/实施计划-CMS应用与Nike主题.md`(不在本仓库内)。 ## 界面截图 本仓库**还没有**放截图(没有 `docs/screenshots/`)。前台这几屏现在只能联机看, 入口是 `http://localhost/site/`(wujies-admin 的 vite 有 `/site` 代理,理由见「开发环境」)。 ## 使用方式 1. 在本仓库根打包(三条命令各管一段,都在**本仓库根**执行): ```powershell powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 -CheckOnly # ① 打包前检查:清单/产物/迁移/菜单/密钥,几秒返回 powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 -Dev # ② 开发包:不签名,落 ..\.plugin-dist\cms\dev powershell -NoProfile -ExecutionPolicy Bypass -File package.ps1 # ③ 正式包:构建 + 用 keys\private.pem 签名 ``` 2. 安装:管理端「应用市场 → 本地安装」上传 zip,或经上架中心(`wujies-market`)发布后由平台拉取。 包内产物路径由 `app.json` 的 `assets` 决定:`frontend` → `frontend/index.js` | `backend` → `backend/wujies-cms-plugin-1.0.0.jar` 清单只有 1 项 `menus`(根「内容中心」,下挂概览 / 文章 / 页面 / 菜单 / 分类 / 标签 / 配置七个子项)。 关于上面三条命令的取值口径: - 判据是「脚本所在仓库根有没有 `app.json`」,所以在本仓库里**不要传 `-Plugin`**(它按子目录找清单, 会报「找不到插件目录」);`-FrontendProject` / `-FrontendDist` 也不用传 —— 独立布局的前端工程固定就是 本仓库 `frontend/`、产物 `frontend/dist`,脚本自己跑 `npm run build`(首次先 `cd frontend && npm install`, 缺依赖时脚本会把这条命令直接报给你)。只想复用现成产物就加 `-SkipBuild`。 - `-SkipFrontend` 只用于**没有前端**的纯后端应用:带前端时它会连「复制前端产物」一起跳过, 出的包只有 `app.json`,装上去没有界面。 - `-CheckOnly` 打出的 `CHECK|级别|项|说明` 行就是管理端「开发工具 → 插件开发 → 打包」抽屉里那份体检清单, 两边跑的是同一套检查;有 ERROR 时正式打包会被直接拦下。签名密钥在本仓库 `keys/` (`private.pem` + `public.pem`,已在 `.gitignore` 里),生成或更换用 `-Keygen` (覆盖已有密钥要显式加 `-KeygenForce`,旧钥先整份备份进 `keys\backup\<时间戳>\`)。 参数全表与产物结构见文档站[「打包应用」](https://docs.wujies.cn/docs/wujies/5.x/platform/package/build)与「密钥与签名」两页。 ## 开发环境 ```bash git clone git@gitee.com:wujies/wujies-cms-plugin.git cd wujies-cms-plugin ``` 最快的路子不用打包、不用重启宿主:启动平台宿主,打开管理端「**开发工具 → 插件开发**」, 找到 `cms` 这一行点「启动后端」(自动编译并加载 `target/classes`,同时开启监听)与「启动前端」 (常驻 `vite build --watch`,保存源码即重建,刷新页面生效,没有 HMR)。全流程见 文档站[「插件开发」](https://docs.wujies.cn/docs/wujies/5.x/platform/plugin)。 手工构建: ```bash mvn -o compile # 后端(宿主 dev-mode 加载的是 target/classes;前提见「现状与前提」) cd frontend && npm install && npm run build # 前端,库模式构建,没有 HMR ``` 后端改完 `POST /app/dev/reload {"appCode":"cms"}` 热加载;模板与 CSS 是资源,`mvn -o compile` 就会复制进 `target/classes`(用 `javac` 手工落类时不会,见「商品从别的应用借」那条末尾)。 **本机验收前台一律开 `http://localhost/site/`**(wujies-admin 的 vite 有 `/site` 代理 → `http://127.0.0.1:8086`), 别用 `http://localhost:8086/site/` 直连:两者不同 origin,前者才和后台共享 `Admin-Token`(见「前台认出后台的登录态」)。 ## 仓库布局 | 位置 | 内容 | |---|---| | `pom.xml` | Maven 模块(父 pom = `cn.wujies.plugins:wujies-plugins-parent`,`` 已置空) | | `app.json` | 应用清单,打包器读的**唯一事实来源**(`code` / `version` / `menus` / `assets`) | | `src/`、`migrations/` | Java 源码(64 个文件)与建表/种子/配置脚本(现 14 个,按文件名排序执行,执行过不许改内容) | | `src/main/resources/templates/cms/<主题编码>/`、`themes/<主题编码>/` | 公开页直出的模板与主题样式、脚本(见下一节) | | `frontend/` | 远程应用源码(27 个文件),入口 `frontend/src/index.ts` | ``` src/main/java/cn/wujies/cms/ controller/CmsPublicController.java 公开页(返回整页 HTML,不是 JSON) controller/CmsThemeAssetController.java 主题静态资源端点 /site/themes/** theme/CmsThemeEngines.java 一个主题一套 SpringTemplateEngine(按需建,卸载时整批丢弃) theme/CmsThemeConfigReader.java 从 sys_config 读 cms.* 站点配置 theme/CmsThemeProperties.java 一次请求的配置快照(主题编码、文案、链接拼接) theme/CmsNavItem.java 导航项(链接在 Java 侧拼好,模板只管遍历) theme/CmsMallFilter.java 商城列表页的分类筛选条(URL 与 active 都在 Java 算) theme/CmsMallFinder.java 本应用唯一的跨应用取数入口:反查商城的 MallCatalogApi theme/CmsToc.java 正文目录:渲染时给 h1–h4 补锚点并抽出目录行 service/CmsMenuServiceImpl.java 菜单:把 cms_menu_item 的引用解析成链接 + 组树 + 整树保存校验 service/… 公开页只读取数(已发布 + 未删除 + 公开可见) src/main/resources/ templates/cms/<主题编码>/**.html 模板(在产物里,不会被静态目录吐出去) themes/<主题编码>/theme.css 主题样式(由上面的资源端点发,当前实测 2685 行) themes/<主题编码>/toc.js 目录高亮(只有观感;禁 JS 时目录仍可点) themes/<主题编码>/site.js 前台客户端脚本(四件事,见「前台认出后台的登录态」) themes/<主题编码>/beian.png 页脚公网安备那行的小图标 themes/<主题编码>/comment-widget.{js,css} 评论区组件产物,由 wujies-ui 的 `pnpm run build:widget` 直接写进这里 —— 是生成物,别手改(见「评论区」一节) mapper/cms/CmsPostMapper.xml 带分类/标签聚合的查询 migrations/ 建表 + 种子数据 + 站点配置(幂等,执行过不许改内容) 06 是 cms_menu / cms_menu_item frontend/ 后台 Vue 远程应用(文章/分类/标签/页面/菜单的列表与编辑器) ``` ## 公开页与自带样式 `/site/**` 整页 HTML 由本插件服务端直出:模板与 CSS、JS 都**在应用产物里**, 所以开发态和生产态同源、同一代类加载器、同一次 reload —— 这是当初把资源端点从 `/remote-apps` 静态目录改成后端直出的原因(详见 `CmsThemeAssetController` 类注释)。 | 路径 | 说明 | 渲染模板 | |---|---|---| | `GET /site/` | 首页:Hero + 精选 + 最新 +(装了商城时)商城精选 + 会员区 | `index.html` | | `GET /site/p/{slug}` | 文章详情;取不到时**返回 404 状态 + 主题自己的 404 页** | `post.html` / `error.html` | | `GET /site/category/{slug}` | 分类列表 | `list.html` | | `GET /site/shop` | 商城商品列表(`?category=` 按分类筛、`?q=` 关键词),数据来自商城应用 | `shop.html` | | `GET /site/product/{id}` | **商品详情**(图集 + 规格 + 正文),同样取自商城应用;商城没装、商品不存在、下架、未审核一律 404 | `product.html` / `error.html` | | `GET /site/{slug}` | **独立页面**(关于 / 联系 / 落地页),别名直接占根一级,对齐 Halo 的 `SinglePageRoute`;别名撞上保留字(`p`、`category`、`themes`…)时保存就报错,公开访问一律 404 | `page.html` / `error.html` | | `GET /site/themes/{code}/{file}` | 主题 CSS / 图片 / 字体 | — | | `GET /site/account` | 账号区取「我是谁」(昵称 + 头像 URL),给 `site.js` 调,**不是页面** | — | | `POST /site/account/logout` | 注销**当前这一个**令牌,给账号区下拉的「退出登录」调 | — | 挂载点写死在 `CmsThemeProperties.PUBLIC_BASE`(`/site`)—— 它必须等于控制器上的 `@RequestMapping`, 所以**不是**配置项:配错了链接全断而端点不会跟着移动。接管根路径 `/` 是阶段 C 的事, 前提是把宿主 `IndexController#index` 那份网站配置接口的消费方先迁走。 ⚠️ 这张表**不含** `/site/apps//**`:那是各应用插件自己发的公开页静态资源 (图床已在用,机制见下面「写主题时必须守住的五条」第 5 条),本仓库没有那个端点,也别来这里找。 `themes/default/theme.css`(当前实测 2685 行)是这一整套前台的基元与共享件; 各应用自己那一屏的样式由那个应用自己发、经 `appStyles` 排到 `theme.css` **之后** —— 边界与理由都在「写主题时必须守住的五条」第 5 条。 ## 现状与前提 本仓库是从 `wujies-plugins` 单体仓库**搬出来的**,三件事的状态如实写在这里: - **后端**:父 pom 是 `cn.wujies.plugins:wujies-plugins-parent:1.0.0` 且 `` 已置空, 平台依赖(`wujies-common-*`,`5.6.2`)也全 `provided` —— 都要在 `~/.m2` 里才编得动。 陌生人 clone 后会卡在找不到父 pom,要等后端 BOM / 构件发布那一阶段。 - **前端**:已迁到已发布的 `@wujies/*` SDK(`ui-shared` / `components` / `api-client` / `ui-plugin-bundler-kit`,`frontend/package.json` 里是 `^5.6.5`)。`frontend/vite.config.ts` 只声明 `defineRemoteApp({ appCode: 'cms', root })`,共享依赖一律 external、运行期由宿主 import map 兜到同一份实例;本仓库前端可独立构建。 - **打包**:本仓库自带**完整**的 `package.ps1`(母本在 `wujies-plugins` 仓库根,各仓用 `node scripts/sync-package-script.mjs` 同步过来),clone 下来就能在本仓库根直接体检、构建、签名、出包、 生成密钥,不需要回到单体仓库,也不需要手工传产物路径。 - **前台样式归属**:商城(`wujies-mall-plugin`)已把专属样式搬走(theme.css 从 3576 行降到 2685 行); **报价的 `.apply` 那一族 172 行还在这份 `theme.css` 里没删** —— `wujies-quote-plugin` 的 `QuoteSiteAssetController` 是新增类、本机宿主尚未热加载, 等热加载验证到 `/site/apps/quote/quote.css` 真回 `200 text/css` 才删那 172 行(删后 2685 → 2513)。 - **本机 8086 宿主跑的是 `profile=prod`,写的就是线上库**:本文所有联机实测数字都是在这台机器上量的, 各自标了日期;重载由维护者手动执行。 ## 站点配置:写在 `sys_config`,不写在 `application.yml` **插件里读不到 `application.yml`。** 插件子上下文是 `AnnotationConfigApplicationContext`, 它不继承宿主的 `Environment`:`@ConfigurationProperties` 绑不上,`@Value("${...:默认值}")` 只会静默拿到默认值 —— 两种都不报错,症状是"改了配置没反应"。平台给应用留的口子是参数表: `migrations/03-create-cms-config.sql` 往 `sys_config` 插 `config_category = 'cms'`、 键名 `cms.` 前缀的行,`CmsThemeConfigReader` 每请求读一次(去前缀后交 `CmsThemeProperties`)。 | 键 | 作用 | 改完怎么生效 | |---|---|---| | `cms.name` | 标题、页脚、OG 站点名 | 刷新页面 | | `cms.description` | meta description 的最后一级回落 | 刷新页面 | | `cms.icp` | 页脚备案号;空串 = 不渲染这一行 | 刷新页面 | | `cms.police` | 页脚公网安备号(整段展示文本,图标 `beian.png`);`policeUrl()` 从文本里取数字拼公安部查询链接,取不出数字就只印文本 | 刷新页面 | | `cms.heroTitle` / `cms.heroSubtitle` | 首页 Hero 两行大字;副标空串 = 不渲染 | 刷新页面 | | `cms.siteUrl` | `canonical` / `og:url` 的域名;留空则不输出这两个标签 | 刷新页面 | | `cms.consoleUrl` | 管理端地址,主题里拼成 `/login`、`/register`、`/member` 三个跳转,并各带一个 `?redirect=<当前前台页>`;留空 = 整块不渲染这些链接 | 刷新页面 | | `cms.themeCode` | 模板与资源用的主题目录名 | **重新加载应用**(引擎按主题分套,装载期定型) | | `cms.regionName` / `cms.regionUrl` | 通知条最左那个站点标识(默认「中国」)+ 它的切换地址;`regionUrl` 空 = 印纯文本,不带地球图标 | 刷新页面 | | `cms.noticeOne` / `cms.noticeOneUrl`
`cms.noticeTwo` / `cms.noticeTwoUrl` | 通知条中间那两条轮播公告(各 5 秒换一条);某条文字留空 = 那条不进列表,链接留空 = 印纯文本 | 刷新页面 | | `cms.helpName` / `cms.helpUrl` | 通知条最右那个入口(默认「帮助」→ `https://docs.wujies.cn`);`helpUrl` 空 = 印纯文本 | 刷新页面 | | `cms.contactEmail` / `cms.contactQq` / `cms.contactWechat` / `cms.contactPhone` | 页脚「联系我们」四行:邮箱(可发信)、QQ 号、微信号、手机号(可拨号);单行留空 = 不显示那一行,四项都空 = 整列不渲染 | 刷新页面 | > ⚠️ 上面这 **8 个通知条键由 `migrations/08-add-cms-notice-bar-config.sql` 建**,后台「配置 → 业务配置」 > 也已摆出这八个输入框。**迁移没跑之前,这八个框是空的**(`sys_config` 里还没有行), > 而前台照旧显示 Java 默认值 —— 于是"框里没字、页面上有字"看着像 bug。 > 更糟的是此时往任何一个框里打字点保存会 **保存失败**:`updateByKey` 对不存在的键影响 0 行并抛「操作失败」, > 不会补插。保存只提交**改动过的**键,所以什么都不动地点保存是安全的。 > 装/升级应用时 08 会自动执行(`migrations/` 目录按文件名排序扫,不用改 `app.json`); > 已经装好的环境要让它生效,就得走一次升级或手工执行那份 SQL。 > 默认值的口径见 `CmsThemeProperties` 那八个字段的注释:**必须是本站此刻真的事**,不能写假促销。 > 曾经的坑(**平台已修**,2026-10-29):`SysConfigBo` 的 `defaultValue` 与 `configValue` 都挂过 `@NotBlank`, > 而 `cms.siteUrl` / `cms.icp` / `cms.consoleUrl` 这三行的 `default_value` 按设计就是空串 —— 在「参数设置」里 > 点保存会 500「默认值不能为空」,连「把值清空以隐藏」都做不到。现在两条校验都去掉了(`@Size` 保留), > **空串是合法值:键值留空 = 该项前台不渲染**。前提是跑的是改过的宿主;老 5.6.2 构建仍会 500。 三条附带好处:后台「系统管理 → 参数设置」直接能改;宿主泛化端点 `GET /open/cms/config` 零改动就能读给 C 端(`name/description/icp` 是它的基础字段,其余进 `settings`); 改值会自动失效 Redis 缓存。 规则:**没有这一行 → 用 Java 里的默认值;有这一行但值为空串 → 按空串**(管理员清空就是想要"不显示")。 配置**按租户隔离**(`sys_config` 是租户表),所以 03 脚本只插了默认租户 `000000`,别的租户要各补一份。 ### 后台「配置」页:复用宿主那对 settings 组件,插件里不写表单 `frontend/src/views/cms/settings/**` 只有三个壳文件(一个 tabs 壳 + 两个一行的包装), 表单体是宿主的 `ModuleWebsiteSettings` / `ModuleBusinessSettings` —— 它们是 **`wujies-admin/src/components/settings/*.vue`,宿主本地组件**,只是经 `wujies-admin/src/plugins/appmarket/runtime.ts` 的 `SHARED_COMPONENTS` 注入给远程应用; 从 `@wujies/components` 来的只有它们里面那个 `SettingForm`。 **所以给这两个页加字段不触发 npm 发布**(改 `@wujies/components` 才要,见 [[plugin-sdk-publish-status]])。 与 IM / 图床 / 考试同一形态。两个 tab 的分工:「网站配置」= `cms.name` / `cms.description` / `cms.icp`, 「业务配置」= `heroTitle` / `heroSubtitle` / **通知条那八条**(`regionName` / `regionUrl` / `noticeOne` / `noticeOneUrl` / `noticeTwo` / `noticeTwoUrl` / `helpName` / `helpUrl`)/ `siteUrl` / `consoleUrl` / `themeCode`。 - **取数与保存走宿主通用接口**:`GET /system/config/list?configCategory=cms` 读,改动的键逐条 `PUT /system/config/updateByKey`。 所以调用者的角色除了 `cms:settings:list`,还得有 `system:config:list` / `system:config:edit`(超级管理员天然有)。 - ⚠️ **`updateByKey` 只改已存在的行**:键不在 `sys_config` 里时影响 0 行,`SysConfigServiceImpl` 直接抛「操作失败」, **不会补插**。页面上出现的每个键都必须先由迁移脚本建好行 —— 这也是本页**不**摆 Logo / 图标 / 背景图那三项的原因: 主题不读 `cms.logo`,摆出来只是一坑。 - 为此动过宿主一次:那两个组件各自有一份按 `category` 分组的字段表与 `Category` 联合类型, 加一档应用就是往表里加一项(`ModuleWebsiteSettings` 顺带把品牌图三项与必填规则改成按 category 取, mall/im/exam/gallery 的字段和校验一字未变)。这是本应用在 pom/yml 之外对宿主的第三处改动。 ## 导航:菜单(`cms_menu` + `cms_menu_item`),不是分类 头部/页脚那排链接来自后台「内容中心 → 菜单」,对齐 Halo 的 Menu / MenuItem: - **一条菜单 = 一个位置**,靠 `code` 认。主题头部取 `code = 'main'`(写死在 `CmsMenu.CODE_MAIN`),页脚复用同一份 `nav` 变量、只渲染顶层。要第二排链接就再建一条菜单, 但模板得跟着改 —— 位置是主题概念,不是配置项。 - **菜单项存引用,不存复制来的 URL**:`source_type` ∈ `home` / `shop` / `post` / `page` / `category` / `url`, 中间四类配 `ref_id`(内容主键),链接在 Java 侧按当前主题/挂载点现算(`CmsMenuServiceImpl.resolveUrls`)。 所以改了文章别名,导航自动跟着变,不会留下坏死链接。`url` 类型才用 `href`, 且只允许 `http://`、`https://`、`/` 三种前缀 —— 挡的是 `javascript:` 混进 ``。 - **`home` 与 `shop` 是本站固定页**:不引用任何内容对象,`ref_id` 与 `href` 一律置空,URL 分别由 `theme.homeUrl()` / `theme.shopUrl()` 现算(后者跟着 `cms.themeCode` 与挂载点走,改主题不用修数据)。 **商城没装时「商店」整条摘掉**(`CmsMallFinder.available()` 为假 → `navItems` 跳过并打一行 info 日志): `/site/shop` 本身会渲染成空态页不至于报错,但导航上摆一条点进去什么都没有的链接,比少一个入口更糟。 后台预览不受影响 —— 摘除只发生在前台解析那一条路。 - **两级 + 新窗口**:`parent_id` 组树,后台拖拽排序(整树 `PUT /cms/menu/{id}/tree`), 主题头部渲染两层(二级是 hover/focus 下拉),`target = '_blank'` 会真的写进 ``。 树最深 5 级(`MAX_DEPTH`),防的是把某个节点拖成自己的子孙之后组树死循环。 - **删了内容不报错**:前台渲染时解析不出链接的项直接跳过(文章转草稿、被删、`href` 不合法都算), 访客看不到死链;后台树里同样的项会标「内容已删除」,让运营去改。这是两种解析模式之间 **唯一**故意保留的差别(`publishedOnly` 参数)。 - **菜单为空 → 回落取前 6 个分类**,见 `CmsPublicController#buildNav`。新租户装完还没进过菜单页时 头部不至于一片空白,同时也是本应用有菜单之前的老行为。 - **前端两处坑(联机实测踩到的)**:`v-hasPermi` 挂在 `` 上不会生效(它的根不是元素 节点,Vue 只给一条 `Runtime directive used on component with non-element root node` 就继续渲染), 所以权限挂在「更多」那个触发 `` 上、把三个权限码或着写在一起;「上级菜单项」的 `el-tree-select` 用 `undefined` 表示顶级,初值给 `0` 会把「0」当成已选值显示出来,保存时再补成后端要的 `0`。 「菜单」管导航,「分类」管归档,两者并存 —— 分类退回它该干的事(给文章分组、列表页按它筛选), 页面里的这一层从「栏目」统一改叫「分类」,避免和菜单混。迁移与种子见 `migrations/06-add-cms-menu.sql` (默认租户 `000000` 建了一条 `main` 菜单和 4 个菜单项;别的租户要各建各的), `migrations/07-add-cms-menu-shop-item.sql` 再往这条 `main` 上补一个 `商店`(`source_type = 'shop'`, 固定 id 9155,`INSERT IGNORE` 幂等,老库重跑只会多这一行)。 ## 商品从别的应用借:Finder 契约(Halo 的做法) Halo 的商城页不是主题写的 —— 数据在插件里,主题只有一份可覆盖的模板。我们同一形态: **商城有数据,主题有版式,中间是一份放在平台 SDK 里的契约。** ``` wujies-common-plugin-sdk/…/sdk/MallCatalogApi.java 契约(四个方法,参数全是标量) MallProductView 商品视图(卡片上那几行,只有展示字段、没有链接) MallProductDetailView 详情(图集 + 规格 + 正文),组合上面那个视图 MallSkuView / MallSpecValueView 规格行与属性值(JSON 由商城解析好) MallCategoryView 分类视图(id / parentId / name) wujies-mall-plugin/…/mall/adapter/MallCatalogApiAdapter.java 实现(@Service,只在插件子上下文) wujies-cms-plugin/…/cms/theme/CmsMallFinder.java 消费方(本应用唯一的跨应用取数入口) wujies-cms-plugin/…/cms/theme/CmsMallCard.java 卡片的展示口径(标签/副标题/划线价/折让怎么判) wujies-cms-plugin/…/cms/theme/CmsMallProduct.java 详情页的展示口径(图集、库存那句话、正文消毒) ``` - **为什么必须"反查"而不是 `@Autowired`**:插件运行时是「宿主上下文 → 每个插件一个子上下文」, Spring 的父子关系单向 —— 插件能看见宿主,宿主和兄弟插件都看不见它。所以 CMS 里注入 `ObjectProvider` 拿到的永远是 null(gallery 的 `FollowRelationApi` 就是这个坑, 它的 javadoc 写着"宿主侧提供",实现却在兄弟插件里,静默降级)。走 `PluginServiceProvider#getPluginBean("mall", MallCatalogApi.class)`,且**每次调用现取**, 这样商城热重载之后 CMS 不会握着上一代对象。 - **为什么契约能对上**:`PluginClassLoader` 对 `cn.wujies..*` 才走子加载器, `cn.wujies.common.*` 一律 parent-first,所以 SDK 里那个 `MallCatalogApi` 在每个上下文都是**同一个 Class**, 按类型查找才有意义。这也是 SDK 只依赖 `wujies-common-core` 的原因 —— 一旦它依赖实现,应用就又和宿主绑死了。 相应地,签名里只有 `Long / String / int` 和两个 SDK 自己的 DTO,**没有** `MallProductBo`、`PageQuery` 这些商城类型。 - **降级是设计的一部分**:`getPluginBean` 约定返回 null 不抛异常,`CmsMallFinder` 再把"商城装了但表还没在线上建" 这类运行期异常也吞成空列表 + `log.warn`。症状必须是"首页少一栏、`/site/shop` 显示还没装商城", 不能是 500 —— 一个应用没装不该把别人的站点打挂。 - **可见性口径是第二份拷贝**(`status=0` + `saleStatus=Y` + `auditStatus=1` + 分类按 `ancestors` 展开子树), 和商城自己的 `OpenMallProductController` 逐条对齐。刻意复制而不是去改那个控制器: 契约只到"取前 N 条",动开放接口是另一件事。两处要改一起改。 ⚠️ `getProduct(id)` **同一条口径要再判一次**:按 id 取数时那三条是唯一能挡住"下架商品被猜地址读走"的东西 (`queryById` 自己不判),适配器取完先验、不合格返回 null,控制器据此出 404。 - **链接一律由本应用拼**,DTO 里没有任何 URL 字段:`CmsThemeProperties` 的 `shopUrl()` / `shopUrl(categoryId, keyword)` / `productUrl(id)`(本主题的详情页)/ `mallCheckoutUrl(id)` / `mallCouponUrl()`。后两条落点是后台会员中心(`cms.consoleUrl`,**另一个 origin**), 没配就是 `null`,模板改印「暂未开放购买 / 暂未开放领取」;`MALL_BASE = "/mall"` 只剩 活动商品详情(`mallActivityDetailUrl`)还在用 —— 商城 SPA 和 CMS 同域,移动挂载点要改代码。 - **卡片上没有「加入购物车」**:商城购物车是 SPA 的 localStorage(键 `wujies-shop:mall:cart`), 后端没有 HTTP 购物车端点,服务端渲染的页面根本加不进去。两个按钮分别是 「查看详情」`/site/product/{id}`(**本站的详情页**)和「立即购买」 `{consoleUrl}/member/checkout/{id}?quantity=1`(出站到后台会员中心,且**要登录**,见下面那条)。 - ⚠️ **`shop` 已加进 `CmsThemeProperties.RESERVED_PAGE_SLUGS`**:`/site/{slug}` 那条独立页面别名路由 直接占根一级,不保留就会让运营建一篇别名 `shop` 的页面把商城页挡掉。 - **首页那一栏要商城里有推荐商品才出得来**:`listRecommended` 按 `mall_product.recommend_flag = '1'` 取数(这列是 `0` 否 / `1` 是,**不是** Y/N —— 同表的 `sale_status` 才是 Y/N,抄错口径不报错, 只会让推荐位永远空的,实测踩过)。线上那两个在售商品的推荐位都是 `0`,所以 `/site/` 现在 看不到这一栏,`/site/shop` 照常出全部在售商品。要看到首页栏,去后台「商城 → 商品」把某个商品设为推荐。 - **商品详情改由本主题直出**(2026-10-30 改判,先前那次打通的是另一条路): 原先「查看详情」指向 wujies-web 的 `/mall/product/{id}`,为了让匿名访客点得通, 给它的 `src/permission.ts` 加了 `whitePrefixList` 前缀放行并实测通过。但那条链在**后台那个 origin 上打不开**: `:80` 是 wujies-admin 的 vite,它只代理 `/dev-api` 与 `/site`,`/mall/**` 落回 admin 自己的 `index.html` ⇒ 后台的 404 页;而 `/mall` 加不进那份代理,因为本站 `base='/'`, 产物里是 `/src/main.ts`、`/@vite/client` 这种根绝对路径,会和后台的 vite 撞车。 定案是**前台的页面都归站点**:详情页搬进主题(`/site/product/{id}` + `product.html`, 数据走 `MallCatalogApi#getProduct`),卡片不再依赖 wujies-web 是否装了、是否放行了。 ⚠️ 「立即购买」`{consoleUrl}/member/checkout/{id}` **仍然出站**:下单、购物车、优惠券都在 SPA 那边, 这一页只负责"看"。2026-10-31 起三页(确认订单 / 收银台 / 支付结果)在后台会员中心, 走登录守卫(未登录先跳登录,带 `redirect`);`cms.consoleUrl` 没配时**没有回退**, 卡片上改印「暂未开放购买」—— 回退到 `/mall/checkout/…` 只会把部署问题盖成 404。 - **改 SDK 要不要重启宿主?取决于宿主怎么起来的**(2026-10-30 实测校准,别再一律按"必须重启"处理): IDEA 直接跑 `WujiesApplication` 时,classpath 是**各模块的 `target/classes` 展开目录** (看 `java.exe … @C:\Users\…\idea_arg_fileNNN` 里那一行,`wujies-common-plugin-sdk\target\classes` 就在上面), 所以 SDK 里**新增**的类只要 `mvn -o compile` 落进那个目录,运行中的宿主第一次用到就能加载,不用重启。 要重启的是两种情况:**改已加载类的签名或字段**(JVM 不会重读),以及宿主是**打包起来跑的** (fat jar 里的 SDK 构建期就定型了)。判据:`Get-CimInstance Win32_Process` 取那条命令行指向的 `@…\idea_arg_fileNNN`,看里面有没有 `wujies-common-plugin-sdk\target\classes`。 ⚠️ 用 `javac` 手工往 `target/classes` 落类时必须带 **`-parameters`**:Spring 靠参数名绑 `@RequestParam Long category`,缺了就报 `Name for argument of type [java.lang.Long] not specified` (实测踩过;Maven 构建自带这个 flag,只有手工编译会漏)。 - ⚠️ **插件仓编译认的是 `~/.m2` 里那份 SDK jar,不是 `target/classes`**:dev-console 的「编译并热加载后端」 跑标准 `mvn compile`,解析到 `cn.wujies:wujies-common-plugin-sdk:5.6.2` 这个已安装产物。所以 SDK 里**新增** 一个契约类,运行中的宿主能立刻用到(上一条),**插件仓却会报「找不到符号 类 MallCatalogApi」** —— 两个现象同源,别误判成代码错。正解是停掉宿主后 `mvn -o install -pl wujies-platform/wujies-common/wujies-common-plugin-sdk` (宿主跑着不许 install,见项目记忆),本机现在靠 `jar uf` 往那个 jar 里追加 class 顶着用, **属临时补丁**:任何一次真正的 install 都会把它冲掉(`target/` 里那份 jar 至今还是新增前的 18 个类)。 给 gallery / 企业查询 / 询价 / 考试加页面时**照这个范式复制**:SDK 里加 `…Api` + 视图 DTO, 提供方插件里加 adapter,CMS 侧加一个 `CmsFinder`。**不要**在 CMS 的 pom 里引对方应用任何 jar。 ## 评论区:组件在 wujies-ui,产物随主题一起发(Halo 的做法) `post.html` 正文下面那一块**不是主题里的片段**,是一个 Vue 组件。但前台这一页没有 Vue、没有 Import Map、也没有 Element Plus,所以照 Halo 那套「主题只出一个挂载点,控件自带产物」分三层: | 层 | 在哪 | 管什么 | |---|---|---| | 组件 | `wujies-ui/packages/components/src/components/KCommentSection.vue` | 版式与交互。只认 `CommentApi` 适配器(`list/submit/like`),不认识任何 URL | | 前台产物 | 同包 `src/widget/comment-widget.ts` + `vite.widget.config.mts` | 把模板印的 `data-cms-*` 翻译成适配器;自带 Vue 运行时,一份自包含 IIFE | | 挂载点 | `templates/cms/default/post.html` 里 `section.comments[data-cms-comments]` | **只印地址**,别的一律不管 | ```bash # 改过 KCommentSection.vue 或 comment-widget.ts 就必须重跑这一条(它故意不在 build / prepublishOnly 里) cd ../wujies-ui/packages/components && pnpm run build:widget # 产出直接落进本仓库(vite.widget.config.mts 的 outDir 就是这里),不需要 dist、也不发 npm: # src/main/resources/themes/default/comment-widget.js (含 Vue 运行时;老记录量的是约 77 KB、gzip 30 KB, # 2026-11-01 重跑后仓库里那份是 88,185 字节) # src/main/resources/themes/default/comment-widget.css # 之后照常规范式打包/热加载本插件(见「连宿主验」) ``` 这两份产物由既有的 `CmsThemeAssetController` 发(`/site/themes/default/comment-widget.js`, 扩展名白名单已有 js/css)—— 挂进主题目录就不必再开一个资源端点,而且与页面**同源**: `Admin-Token` 那条 `Authorization: Bearer` 直接打得出去,没有 CORS。 ### 模板要印的那六个属性 ```html
``` - `comment-base` → 社交应用的 `/site/comment`(`GET /tree`、`GET /replies`、`POST`、`POST /like`,见 `wujies-social-plugin` 的 `SocialSiteCommentController`); - `author-api` → 会员应用的 `/site/member/profiles?userIds=1,2,3`(昵称 + 等级 + 勋章,见 `MemberSiteController`);评论行里只有 `userId`,头衔必须问会员那边; - `account-url` → 本页已有的 `GET /site/account`:登录时**并行**问一次身份(不挡评论首屏), 问出来之前编辑器那一格留空,问出来才印上我自己的昵称与头像;没带凭据则当场就是 「登录后可发表评论」那一行; - `widget-css` → 样式由脚本自己插进 ``(少这一条就出一块没样式的评论区)。 后两条路径**不在模板里写死**:它们属于别的应用,CMS 只在 `CmsThemeProperties` 里留一份 同名常量(与 `productUrl` / `galleryUrl` 一个口径 —— 共用的是 URL 这条约定,不是代码)。 属性印漏时脚本**不挂载**,页面仍是完整的文章页 —— 与主题其余交互「没有脚本也还是一页」同一条底线。 **树的形状是服务端说了算的一条约定**:`GET /tree` 只回**两层**,回复的回复一律摊平到 它所在那一栋的 `children` 里(按 `createTime` 升序)。组件只渲染两层,多出第三级会挂在 屏幕看不见的地方,而评论数照样把它算进去;「回复 @谁」那一句靠 `replyToUserId` 在同一栋里找人。 同一栋**默认只带前 2 条回复**(`SocialSiteCommentService.REPLY_PREVIEW`),剩下的走 `GET /replies?targetType=&targetId=&rootId=`:首屏不必把整篇的回复与作者全拉出来, 但一栋有回复就仍然看得见样子(有回复的栋自然出 2 条,没出的一条不拉 —— 不做"随机出几条", 同一篇文章刷两次版式会跳)。`tree` 里那一栋的 `replyCount` 回的是**这一次真查出来的回复数** 而不是 `reply_count` 那一列:那一列只在发评论时自增,后台删过回复就再也对不上, 前台「展开其他 N 条回复」会报一个不存在的数。2 这个数字与组件的 `replyPreview` prop 是同一把尺子, 改一侧要改另一侧。 `--k-cw-*` 那一组配色变量写在 `theme.css` 的 `.comments` 上(组件内部把它接成自己的 `--cw-*`): **变量必须设在组件外层**,同名写到组件根节点会被它自己那条声明盖住。版式(行距、圆角、头像尺寸) 留在组件里,换主题只改配色。 验收口径(2026-11-01,本地 mock 服务器起 4 个接口跑的):匿名进 → 整块评论可读、输入框换成 「登录后可发表评论」、回复按钮不出现;带 token 进 → 编辑器出昵称头像、回复落到对应楼中并即时 带上自己的等级与勋章、点赞先加一再用服务端计数覆盖;会员查不到的那位画成「匿名 + 没头衔」而不是 整条丢掉;勋章图挂不上就退回名字前两三个字(与后台勋章墙 `showBadgeImage` 同一把尺子)。 同一份 mock 上还量过:加载中出三行骨架(头像 + 昵称 + 两行正文,`aria-busy="true"`, `prefers-reduced-motion` 下不抖),楼中楼那格回复框是纵向的(输入框占满宽度, 「取消 / 发布回复」在下一行右侧、彼此留 10px),点赞那颗是 24×24 描线图(14×14 显示)。 2026-11-01 第二批(同一份 mock,改成"每栋只给前 2 条 + 一个 `/replies`"的形状)量到: 一栋 5 条回复时收起状态出 2 条与「展开其他 3 条回复」,点开变 5 条 + 「收起」, **收起再展开不再发第二次请求**(判断"要不要问"看的是已拿到几条,不是当前画了几条, 写成后者会把同一批回复再问一遍);只有一条回复的那栋不出按钮;头部「评论 10」在展开收起时不动。 楼号按发帖顺序定(4/3/2/1,最新在前),切到「最热」只换行的次序、楼号跟着人走。 表情面板 105 枚分 4 组,搜「狗头」只命中狗头那一枚,点一枚插进光标处(选中两段字会被它换掉, 成对码点按 UTF-16 偏移量落光标:在「这张」与「图不错」之间插那一枚,实测 selectionStart=4), 选完不关面板、点面板外或再点笑脸才关,「最近使用」记在 `localStorage` 的 `wujies-comment-recent-emoji`(最多 12 枚,隐私模式读写都包了 try/catch)。 等级与勋章那两枚小牌改成**白底 + 一条描边**:楼中楼整格铺 `--cw-fill`, 牌原来铺同一档灰,落在一起就只剩字(实测牌面 `rgb(255,255,255)` 对格子 `rgb(246,246,247)`,描边 1px)。 联机侧(2026-11-01,`http://127.0.0.1/site/p/hello-cms`,文章 id 9130)已验到:三个新控制器都返回 200, 六个属性齐全印出,`comment-widget.js|css` 同源取到并挂载成功。 服务端耗时(本机 → 远程库那台测试机 150.158.109.115,热连接,`curl -w %{time_total}`): `GET /site/comment/tree` 45–50 ms(换一个没评论的 target 是 53–67 ms,也就是 SQL 只占约 20 ms, 其余是框架与鉴权的固定开销);`GET /site/member/profiles?userIds=1` 160–265 ms, 串 8 个 id 是 341 ms;`GET /site/account` 5–7 ms(早先记的「约 210 ms」是冷连接那一次,作废)。 `profiles` 慢在**一次请求串了 6 趟远程库**(用户 / 会员行 / 等级 / 勋章授权 / 勋章 / 头像 oss), 每趟约 30–45 ms —— 树改成只带前 2 条回复以后,这一问要查的作者数从"整篇"降到"首屏那几张面孔", 但趟数本身还没减。 **2026-11-01 已把 6 趟减到 4 趟**(改在会员插件,两条 join SQL 各吃掉一趟): `MemberMapper.selectMembersWithLevelByUserIds`(会员行 join 等级,`del_flag` 写在 `ON` 里, 所以 level_id 为空或等级被软删时会员行仍然回来 —— 前台画成一个没有头衔的名字); `MemberBadgeGrantMapper.selectGrantedBadgesByMemberIds`(发放记录 join 勋章定义, `INNER JOIN` + 定义侧 `del_flag = '0'`,与改造前"查不到定义就跳过这枚"同一个结果, 排序仍是 `grant_time desc, id desc`,每人去重留在 Java 里)。 `sys_user` 与 `sys_oss` 那两趟没动(跨模块,靠宿主的 SDK 能力)。 本机 JDBC 只读核对(同一把尺子跑新旧两组 SQL):新 SQL 在线上 schema 解析通过、 会员+等级两边结果逐字段一致(库里 1 位会员、7 个等级、3 枚勋章定义、**0 条发放记录**, 所以勋章那条只验到"解析与列名对",返回空集),单趟耗时 23–26 ms,预计这一问省约 50 ms。 **接口侧的实测还没做** —— 这改的是 Java,本机 8086 上要 `POST /app/dev/reload` 加载会员插件才生效; 生效前测到的仍是旧口径(2026-11-01 重载前复测:`?userIds=1` 170–295 ms, 串 8 个 id 444 ms)。复验口径: `curl -s -o /dev/null -w '%{time_total}\n' 'http://127.0.0.1:8086/site/member/profiles?userIds=1'`, 再用同一批 8 个 id 跑一次与上面那两个数对比。 评论树**两层摊平**那条已在联机生效:9130 现回顶层 1 条、楼中楼 3 条(回复的回复与它回复的那条 同级,靠 `replyToUserId` 说明接的是哪一条),页面标题「评论4」。 **「每栋只带前 2 条 + `/replies`」这一条改的是 Java,本机 8086 上还没生效**:生效前旧服务会把 3 条回复一次全给,组件按"已经拿到手"判断,那颗按钮最多说一句「展开其他 1 条回复」而点开只是把 已在手的那条画出来,不会报错也不会多发请求。 两处留意:改过 `KCommentSection.vue` / `comment-widget.ts` 要重跑 `build:widget` 并把产物 `cp` 进 本插件的 `target/classes/themes/default/`(静态资源与模板现读 classes,不用热加载); **Java 侧改动仍要插件被热加载**(`POST /app/dev/reload`),而本机 8086 宿主跑的是 prod profile、 写的就是线上库,重载由维护者手动执行。 ## 宿主侧只需要两处 1. `wujies-admin/pom.xml` 引 `org.thymeleaf:thymeleaf` + `thymeleaf-spring6`(**纯库,不是 starter**)。 `PluginClassLoader` 是 parent-first,引擎必须由宿主提供;不引 starter 是为了不往全 JSON 的宿主上 装一个全局 `thymeleafViewResolver`。插件侧一律 `provided`。 2. `application.yml` 的 `open-api.tenant.path-patterns` 加 `- /site/**`, 让匿名公开页落进既有的租户解析那一条带(取值顺序仍是 query `tenantId` > header `X-Tenant-Id` > 默认租户)。 鉴权没有扩大匿名面:`/site/**` 靠控制器上的 `@SaIgnore` 放行,与 `/portal/enterprise/**`、`/open/gallery/*` 同一套既有做法。 ### 上线时的一条前提:`/site/**` 必须挂在域名根上 模板里的链接全部是**根相对路径**(`/site/`、`/site/p/`、`/site/themes/default/theme.css`, 由 `CmsThemeProperties` 的 `PUBLIC_BASE = "/site"` 拼出),没有可配的路径前缀。 所以反向代理必须把 `site` 整段映射到**后端 origin 的根**; 若沿用管理端那种带前缀的地址(`VITE_APP_BASE_API = https://api.wujies.cn/v1/prod`), `/v1/prod` 会被根相对链接吃掉,页面能出但 CSS 与内链全 404。 后台改配置、文章列表、主题样式在 8086 直连下均已实测通过(见「连宿主验」), 网关那一段的重写规则本机没有副本,**尚未验证**。 ### 前台 → 后台的登录回跳(`?redirect=`) 主题里「登录 / 个人中心 / 立即登录」三个链接都带 `?redirect=`,值是**当前这一页的绝对地址**: 优先 `cms.siteUrl` 拼出来的 `canonical`,没配站点域名时回落请求行 URL(`CmsPublicController#returnTo`)。 后台 `?redirect=` 这个参数名不是新造的,`src/permission.ts` 未登录拦截时本来就在写它。 - 绝对地址只能交给浏览器整条换页:`router.push('http://…/site/')` 得到的是空白路由。 `wujies-admin/src/utils/loginRedirect.ts` 负责分辨"本站路由"还是"前台绝对地址",后者走 `window.location.replace`。 - ⚠️ **主机必须比,否则这是一枚开放重定向**(任何人把用户从登录页拐去假后台都能成立)。 但比的是 **hostname 而不是 origin**:开发态后台在 `:80`、后端在 `:8086`,端口天生不同; 并且回环地址的三个名字(`localhost` / `127.0.0.1` / `::1`)按同一台机器看待 —— 本机 `cms.consoleUrl = http://127.0.0.1`,而前台页面上的 `redirect` 是 `http://localhost:8086/site/`, 严格比 hostname 会把这条合法回跳判掉。只放开环,`localhost.evil.com` 仍然挡。 ### 前台认出后台的登录态:`site.js` 干四件事 令牌只在 `localStorage` 的 `Admin-Token` 里(`is-read-cookie: false`,服务端不发 Set-Cookie), **SSR 拿不到登录态** —— 服务端既不知道你是谁,也不该猜。页面先按未登录印完, `themes/default/site.js` 再在客户端补状态,一共四件事: | 做什么 | 靠什么 | 守什么 | |---|---|---| | 通知条轮播 | `data-cms-notice` 上每 5 秒换一条 | 只有一条 / 标签页在后台 / 系统开了「减少动态效果」 ⇒ 不装定时器 | | 点击展开搜索 | 导航条那个图标切 `is-open` | 展开即聚焦输入框;点外面或 Esc 收起;`aria-expanded` 同步 | | 收起筛选 | 目录页那个按钮切状态 | ≥992px 收侧栏(`.shop.is-filters-off`),<992px 改底部抽屉 + 遮罩 | | 账号区 | 读 `Admin-Token` → `GET /site/account` | 见下面两段 | 前三件不碰身份,所以**在登录判断之前就装好**;第四件才需要凭据。 **跨端口那一半交给标记 cookie** `wujies-site-state`(由后台 `setToken` / `removeToken` 写与清): cookie 的作用域不含端口,所以本机「后台 `:80` + 前台 `:8086` 直连」也能认出登录态。 代价是这一侧只剩一个布尔值、换不到身份 —— 没有 token 就问不出昵称,此时印「我的账号」 且**把退出按钮从 DOM 里删掉**(留一个按了没反应的按钮更糟)。给 `` 加 `is-logged-in` 之后, `theme.css` 据此 `[data-cms-guest]{display:none}` / `[data-cms-user]` 反向显示 (写 `display: initial` 会把 `.member__cta` 自己的 `inline-block` 抹掉,所以显式写了行内级)。 **取身份走 `GET /site/account`**(`CmsAccountController`)。这条路径 `@SaIgnore` 且不在 Sa-Token 的 登录校验清单里,所以公开页**不需要**后台那份 `clientid` 头 —— 那是构建期注入管理端的常量, 公开页拿不到,也不该拿到。响应 `data: null` 是四种情况的同一个画法(没带 token / 已过期 / 账号已删 / 宿主没提供 `PlatformUserApi`),前台一律留「我的账号」,不猜是谁。 > ⚠️ 控制器里**必须先 `LoginHelper.isLogin()` 再看 `getUserId()`**:后者只解 JWT 载荷, > 会话已注销的旧 token 照样读得出 `loginId`。不挡就等于把昵称与头像交给一枚作废的字符串, > 直到它自然过期为止。2026-10-30 实测收口:带旧 token 时 `/system/user/getInfo` 已 401, > 本接口当时还返回身份;现在 `/site/account/logout` 之后拿同一枚 token 再问,回 `data: null`。 > 昵称只能进 `textContent`(`innerHTML` 等于把存储型 XSS 请进头部)。 前提是**前台页与后台同源**(localStorage 按 origin 隔离),这只影响"能不能换到身份",不影响"认不认得出登录过": - 生产:同域 + 路径前缀(见 wujies-web 拆分定案),`https://域名/site/` 与 `https://域名/login` 同一个 origin ⇒ 正常。 - 本机:只能走 vite 的 `/site` 代理(`wujies-admin/vite.config.ts` 里把 `/site` 转到 `http://127.0.0.1:8086`), 在 `http://localhost/site/` 上打开才有 token 可用;**直接开 `http://localhost:8086/site/` 是另一个 origin**, 读不到 `Admin-Token`,账号区只剩标记 cookie 给的那个布尔值(有「我的账号」,没有昵称,也没有退出按钮)。 这不是 bug,是 origin 隔离。 ## 本地验收 ### 不启动宿主也能验模板(推荐先跑这个) Thymeleaf + SpEL 的表达式错误只有在渲染时才会暴露,而线上一次渲染要连"装插件 + 建表"两步副作用。 所以先用一个离线渲染自检:把模板与片段在真正的 `SpringTemplateEngine` 下渲染成静态 HTML (现在跑的是 index × 2(有商品 / 空)+ shop × 2(有商城 / 没装)+ product × 3(图集+可分组规格 / 只有规格名 / 三样全空)= **七份产出**,商品数据是假的 `MallProductView` / `MallProductDetailView` 喂进去的)。 ```bash mvn -o -B -q dependency:build-classpath -Dmdep.outputFile=target/cp.txt # 用 CmsRenderCheck(临时脚手架,不放进仓库)渲染 index / shop / list / post / page / error / 空数据 几份产出 javac -encoding UTF-8 -cp "target/classes;$(cat target/cp.txt)" -d /classes /CmsRenderCheck.java java -cp "/classes;target/classes;$(cat target/cp.txt)" CmsRenderCheck ``` ⚠️ 断言里比对 URL 时记得 `&` 在产出里是 **`&`**(Thymeleaf 转义属性值): 按 `?category=1&q=x` 原文断言会假失败,第一次跑就撞上过。 它会打印每页字符数,并对产出里残留的 `th:` 告警;顺带断言配置的三条语义 (缺行用默认值、空串按空串、非法主题编码回落 `default`)、链接拼接结果,导航的两侧 (一项带二级 + 一项 `target="_blank"` 时,`index.html` 里要有 `nav__drop`、`target="_blank"` 和拼好的二级链接),以及目录的两侧: 有标题时 `post.html` 里 `aside.toc` 与正文锚点成对出现,无标题时整个侧栏不渲染。 它同时断言**分支互斥**与**读取侧那道消毒闸门**:needle 前加 `NO:` 表示"这个必须不出现" —— 详情页有属性名可分组时 `NO:pdp__skus"`(两套规格表印在同一页上最难看),正文里 `NO:alert(` / `NO:onerror=` / `NO:javascript:`(商城富文本原文带着这三种,消毒后必须都没了)。 ⚠️ 这里**不能用 `NO:`, 断言写粗了会永远红。"只能有一个"这种语义 substring 判不出来(每条都印上 `--on` 也能命中断言), 用计数:`aria-pressed="true"` × 1(缩略图只有一个选中态)、`pdp__sku-was` × 1(原价等于售价的那条规格不画线)。 ⚠️ 跑它要在 `-cp` 里补 `~/.m2/.../jsoup-1.18.3.jar` —— 正文目录(`CmsToc`)解析 HTML 用的就是它, 宿主那边由 `wujies-common-core` 带(compile 作用域),插件只声明 provided,不进 `target/cp.txt`。 渲染结果可以直接用浏览器打开看版式(内置浏览器不吃 `file://`,用一个本地静态服务器喂, CSS 会一并复制到 `/site/themes/default/` 这个挂载点上)。 ### 连宿主验 1. 宿主以 `app-market.plugin-runtime.dev-mode: true` 启动,本仓库在项目根 `plugins/` 下会被自动扫描。 ⚠️ 本机 8086 宿主跑的是 `profile=prod`,**写的就是线上库**:装应用会建 `cms_*` 表(含 06 的 `cms_menu` / `cms_menu_item`)、同步 `sys_menu`、往 `sys_config` 插 8 行 `cms.*`。 2. `mvn -o compile` 后 `POST /app/dev/reload {"appCode":"cms"}`,看 `componentCount/endpointCount/mapperCount` 都 > 0。 3. 开发台「本地安装」建表 + 同步菜单 + 落配置。 4. `curl -i http://localhost:8086/site/p/hello-cms` —— 期望 **200 + 整页 HTML**(种子数据里有这篇)。 这一步同时证明「引擎能从插件产物里解析模板」和「`@SaIgnore` 在插件路由上生效」。 5. `curl http://localhost:8086/open/cms/config`(匿名)能看到那 8 条;在后台改 `cms.heroTitle` 后刷新 `/site/` 应当立刻变。**这条是"配置真的通"的判据**——`application.yml` 路线上这两种做法都没有任何症状。 (2026-10-30 已实测:后台「内容中心 → 配置」改 `cms.icp` 保存,`/site/` 页脚立刻多出一行; 清空再保存,那一行跟着消失 —— 空串是合法值,不会被 `@NotBlank` 拦。) 6. 反复 reload 5 次数 `PluginClassLoader` 实例数,确认没有一代一泄漏(`stop()` 会丢弃整批主题引擎)。 7. 菜单:`curl -s http://localhost:8086/site/ | grep -o 'nav__drop'` 要有输出(种子那条 `main` 菜单 的二级渲染出来了),后台「内容中心 → 菜单」里把某项拖到另一项下面,刷新 `/site/` 应当跟着变 —— 拖拽落点走的是 `PUT /cms/menu/{id}/tree`,返回 500「提交的菜单树少了 N 项」就是前端少送了节点。 (2026-10-30 已实测:把「技术手记」拖到「产品动态」下面,前台立刻出现 `nav__drop`;重进页面层级仍在。) 8. 商城契约(2026-10-30 已实测,**没停宿主**)。步骤: `mvn -o -B compile -pl wujies-common/wujies-common-plugin-sdk`(在 `wujies-platform/` 下跑,只 compile 不 install) → 两个插件各 `mvn -o compile`(**当前不行**:`~/.m2` 里那份 SDK jar 还是 18 个类、没有 `MallCatalogApi`, 要先把宿主停掉 `mvn -o install -pl wujies-common/wujies-common-plugin-sdk` 才能用 Maven 编; 本轮为了不动宿主,改用 `javac -parameters -d target/classes` 手工落进插件的 `target/classes`, classpath 用 `mvn -o dependency:build-classpath -Dmdep.outputFile=target/cp.txt` 生成、 再把 SDK 的 `target/classes` 排在最前)→ 模板与 CSS 是资源,`javac` 不会复制, 要手工 `cp src/main/resources/templates/... target/classes/templates/...`(漏了就是页面能出但没样式/找不到模板)。 cms 是「监听中」的,改完约 20 秒自动生效;**mall 没在监听**,得在开发台 「开发工具 → 插件开发」那一行点「更多 → 热加载」,对话框里 appCode 与产物路径都预填好了, 点「热加载」只重建子上下文、不碰数据库。 ```bash curl -s http://localhost:8086/site/shop | grep -c 'class="pcard"' # 实测 2(线上在售商品就 2 个) curl -s http://localhost:8086/site/shop | grep -c 'filter__chip' # 实测 11 个分类,未选分类时不出「全部」 curl -s 'http://localhost:8086/site/shop?category=2055188086818668545' \ | grep -o 'filter__chip--on[^>]*>[^<]*' # 实测只有 T恤 亮,且多出「全部」 curl -s 'http://localhost:8086/site/shop?q=JEEP' | grep -o 'band__sub">[^<]*' # 实测 1 个在售商品 curl -s 'http://localhost:8086/site/shop?q=JEEP' | grep -o 'name="robots"[^>]*' # noindex, follow curl -s http://localhost:8086/site/ | grep -c 'class="pcard"' # 实测 0:推荐位没商品,整节不渲染 ``` 浏览器实测 `/site/shop` 版式正常(分类条 + 搜索框 + 两列商品卡,价格/划线价/销量/商家/两个按钮都对齐在卡底); **两张卡的封面图 403** —— 那两个商品的 `galleryUrls` 指向 `https://oss.wujies.cn/image/2026-05-*/….jpg`, 带不带 Referer 都是 403,商城 C 端同样取不到,属于商品数据/对象存储权限的既有问题,与本页无关。 日志那行是 `[cms] 商城页渲染完成 主题=default 商城=true 分类=null 关键词= 商品=2 耗时=525ms` —— `商品=0` 而 `商城=true` 是"装了但库里没有在售商品",和 `商城=false`(没装)是两种症状,别看错。 ⚠️ 顺带测出**商城开放接口的一个既有故障**(本轮没改):`/open/mall/product/list?pageSize=2` 序列化第 2 行时抛 `JsonMappingException: Can not write a string, expecting field name` (引用链 `MallProductVo["merchant"] -> MallMerchantVo["merchantLogoUrl"]`),`pageSize=1` 正常; 这条契约只取 `merchantName` 字符串,所以没受影响。 ## 写主题时必须守住的五条 1. **判空写 `X != null and X != ''`,不要写 `#strings.isNotEmpty(X)`**: 模板走 SpEL,SpEL 按参数类型找方法,`isNotEmpty(null)` 报 `EL1004E: Method isNotEmpty(null) cannot be found`,整页 500。 封面这种常为空的字段用 `post.hasCover()`(零参数,没有这个问题)。 2. **`th:utext` 只准出现在正文那三处**(`post.html`、`page.html` 的 `article__body`,加 `product.html` 的商品详情)。前两处输出的是入库前已由 `HtmlSanitizer` 消毒的 HTML —— 闸门在保存链路 (`CmsPostServiceImpl` / `CmsPageServiceImpl`),模板不做过滤;`/cms/post/**` 与 `/cms/page/**` 在宿主 `xss.excludeUrls` 里,排除即没有兜底。第三处反过来:`mall_product.detail` 由商城后台写, 本应用管不到它的保存链路,所以闸门在读取侧(`CmsMallProduct` 里过一次 `HtmlSanitizer.clean`)。 3. **片段引用只准用主题目录内的相对名**(`~{modules/head :: head}`),不准写成 `default/modules/head`: 每个主题一套引擎、解析前缀就是它的目录,所以整份拷贝 `templates/cms/<新编码>/` + `themes/<新编码>/` 再改 `cms.themeCode` 就能换主题。前缀一旦收到 `templates/cms/` 这一层, 主题就得在自己的模板里写自己的编码,"主题 = 一个自包含目录"当场破掉(踩过,实测 `TemplateInputException`)。 4. **动效只用 `theme.css` 里那两个令牌**:`--dur: .3s` 与 `--motion: cubic-bezier(.7,.3,.1,1)`。 这条曲线是 2026-10-30 从参考站(Converse 商品目录的「收起筛选」抽屉)实测 `getComputedStyle` 抄来的, 通知条轮播、点击展开搜索、收起筛选三处共用 —— 别再各自写一遍 `ease`,四处不同步的动效比没有动效更显眼。 5. **应用自己那一屏的样式不在这个仓库里**:`modules/head.html` 那条 `th:each="${appStyles}"` 是唯一的口子 (排在 `theme.css` **之后**,所以应用样式盖得动基元)。链接清单由应用的控制器作为页面变量塞进来, 文件与端点都在应用插件里 —— 图床是第一个这么做的(`wujies-gallery-plugin` 的 `site-styles/gallery/gallery.css` + `GallerySiteAssetController` + `GallerySiteRender`)。 **为什么端点必须跟着插件走**:插件各自的类加载器,本仓库的 `CmsThemeAssetController` 显式按自己的加载器取资源,读不到兄弟插件的产物(模板能跨过去是因为 渲染时应用把加载器交了过来,静态资源是一次独立请求,没有那次交接)。 **搬的边界**(2026-11-01 用 Node 扫六个应用的 `*.html`,按"几个模板在渲染这一族"定,不看类名前缀): `shop__list` 五个应用、`filter__chip` 五个、`pager` 四个、`row-list` / `row-facts` 三个 —— 这些留在本仓库; 只有一个使用者的整族跟着插件走:`.facts`→图床、`.apply`→报价、`.toolbar` / `.entrance*` / `.entry*` / `.store*` / `.pdp*` / `.campaign` / `.coupons` / `.shop__price*`→商城、`.wall` / `.gcard` / `.shot` / `.upload` / `.lightbox`→图床。搬过去的规则只准用主题的 CSS 变量, 否则阶段 B 换主题就是"骨架换了肤、那一屏还是旧配色"。 两个反直觉的判例:`.acard`(活动卡)与 `.ccard`(券卡)看着像商城的,其实 `templates/cms/default/modules/activity-card.html` 与 `coupon-card.html` 也在渲染它们(往文章里插一张卡), 所以**留在本仓库**;`.pcard` 同理(商城目录页那是一张 cms 模块出的卡)。反过来, `.shop__price` 前缀像共享目录列的一部分,但只有商城的目录页在用,所以搬走;连那六条 "在商城作用域里覆盖共享基元"的规则一起走(`.toolbar .search`、`.btn.is-off`、 `.pdp__detail .section__title` 等)—— 它们靠 `` 排在 `theme.css` 之后才盖得住。 共享件的**变体**不搬:`.filter--tight` 跟着 `.filter` 留下,`.btn--off`、`.min-wrap` 跟着各自基元留下。 合写的选择器拆开算各自的:`.shop__group > .shop__list`(四个应用)留下,同一块的 `.shop__group > .shop__price`(只有商城)跟 `.shop__price` 一族走。 2026-11-01 商城是第二个这么做的(`wujies-mall-plugin` 的 `site-styles/mall/mall.css` + `MallSiteAssetController` + `MallSiteRender`,theme.css 从 3576 行降到 2685 行; 报价的 `.apply` 那一族 172 行已经写进 `wujies-quote-plugin` 的 `site-styles/quote/quote.css` 并接好端点, 等报价插件热加载一次、`/site/apps/quote/quote.css` 真能出 `text/css`,再把本仓库这一段删掉 —— 少 172 行到 2513)。 **`:root` 那套令牌不跟着搬,也不准在应用样式里重抄一份**:编辑器看不见兄弟插件的 jar,会在应用 CSS 的 `var(--ink)` 下面提示"未知自定义属性",那是 IDE 的限制不是运行时没生效(自定义属性全局作用域, 而且 `head.html` 无条件把 `theme.css` 排在前面)—— 2026-11-01 用 `getComputedStyle` 在 `/site/gallery` 实测取到 `#111 / #2e2e30 / #e5e5e5 / .3s`,别为了消掉编辑器黄灯把数值抄第二份。 ## 还没做 - **`/site/product/{id}` 只差联机验收**:代码三仓都写完并且已经构建过(2026-10-30:SDK `mvn -o -B install` 成功 —— 宿主当时没跑,所以能装;商城与 CMS 各自 `package` 出 jar;离线渲染 7 份产出全绿)。 剩下的就是等 8086 宿主起来,然后按「连宿主验」那条判据点一遍: `POST /app/dev/reload {"appCode":"mall"}`(这个接口签名变了,旧 jar 里的实现不认)。 **商城必须热加载一次**:开发台「开发工具 → 插件开发」那一行点「更多 → 热加载」, appCode 填 `mall`、产物指到 `wujies-mall-plugin/target/*.jar` —— 磁盘上装着的旧副本里没有 `getProduct` 的实现,光重启宿主拿到的还是旧 jar,接口一调就 `AbstractMethodError`。 cms 那侧在监听,`mvn -o compile` 之后约 20 秒自己生效。 下架商品那条尤其要验:`queryById` 不查可见性,是 `getProduct` 自己补的三道判断,只有真数据能证明它拦得住。 验收判据:`/site/shop` 点「查看详情」进本站详情页、地址栏仍是 `/site/product/…`, 且用一个已下架/未审核商品的 id 直接访问要出主题的 404 页而不是商品页。 - **报价的 `.apply` 样式还压在这份 theme.css 里**:那 172 行(2685 → 2513)要等 `wujies-quote-plugin` 的 `QuoteSiteAssetController` 被宿主热加载一次、`/site/apps/quote/quote.css` 实测回 `200 text/css` 才能删 —— 现在删了前台就没样式(见「写主题时必须守住的五条」第 5 条与「现状与前提」)。 - **通知条那 8 个键要等一次升级才落库**:SQL(`migrations/08`)与后台表单(宿主 `ModuleBusinessSettings.vue` 的 `cms` 字段表 + `configKeyMap`)都已就位,但本机这个**已经装好的**环境 还没跑 08 ⇒ 八个框是空的、打字保存会失败(原因见上面那个 ⚠️ 块)。补跑要写线上库,等点头。 顺带一条:`ModuleWebsiteSettings.vue` 的 `FieldKey` 是个封闭联合类型(只有品牌图 + 那五个), 往「网站配置」页加键得先扩它;「业务配置」页的 `moduleFieldMap` 是自由键,**加项请优先加在那边**。 - **目录页没有排序控件**:`MallCatalogApi#listProducts` 只按后台 `sortOrder` 升序返回, 契约里没有排序参数,加控件就得先加契约。同理**没有翻页**(只有 `limit`,上限 60)。 - **筛选只有分类 + 关键词**:价格区间、颜色、尺码三组都没做 —— 商品表里没有可筛的规格数据 (`specConfig` 是一段 JSON 字符串,不是列)。 - **搜索只搜商品**:导航条那个框提交到 `/site/shop?q=`。全站(文章 + 页面 + 商品)没有搜索端点, 所以这一条不是"入口没做",是**后端没有能查的东西**。 - **`assetUrl` 的 `?v=` 是主题编码,不是版本**:同一套主题改了 CSS,访客浏览器上的旧文件不会失效。 正解是接产物指纹,那要等主题打包进发行版本体。 - **开发态「个人中心」要跨主机名跳**:`cms.consoleUrl` 配的是 `http://127.0.0.1`, 而前台是从 `http://localhost` 打开的。`alignConsoleHost` 只把**回环别名**归一(`localhost` / `127.0.0.1` / `::1` 算同一台机器),非回环的域名不一致时**故意不猜**,原样保留。 - **其它应用的前台页**:本轮只打通商城一个模块。图床、企业查询、询价按同一范式各加一份契约 + 一个 `Cms…Finder` + 一个模板;考试与即时通讯要不要进前台还没定(这两个的公开页语义和商城不一样, IM 基本没有"公开可看"的东西,考试是另一套权限模型)。 - **文章编辑器抽成可复用组件**放进 `@wujies/components`:现在文章与页面两个编辑页各自引宿主的 全局 `WangEditor`,企业/商城的富文本也该共用同一套(发 npm 前要二次确认)。 - **本仓库还没放截图**(`docs/screenshots/` 不存在)。 - 定时发布(`status=scheduled` 有列、没有到点转 published 的任务)。 - 列表页真正的分页:`/site/category/{slug}?page=2` 现在只接参数、不改内容,靠 `noindex` 兜住重复 URL。 - 阶段 B:多主题目录与回落、`theme.yaml` 与主题选项表单、`cms_menu` 站点导航、按 Host 定租户、 `sitemap.xml` / `robots.txt`、JSON-LD 结构化数据。 ## 许可 商业授权:需要一枚覆盖本应用的有效授权凭证才允许安装与加载(条文见仓库根目录 `LICENSE`)。清单 `app.json` 里 `license` 为 `UNLICENSED`、`licenseRequired` 为 `true`;三档口径见官网文档「授权与版本」。 ## 参考 [`wujies-gallery-plugin`](https://gitee.com/wujies/wujies-gallery-plugin)(第一个自带公开页资源端点的应用)、 [`wujies-mall-plugin`](https://gitee.com/wujies/wujies-mall-plugin)(第二个,样式搬走的判据就是它定的)、 [`wujies-quote-plugin`](https://gitee.com/wujies/wujies-quote-plugin)(`.apply` 那一族的去处,端点已写好等热加载), 文档站[「插件开发」](https://docs.wujies.cn/docs/wujies/5.x/platform/plugin)章节。