diff --git a/.env.development b/.env.development index d4e241c..8865a7e 100644 --- a/.env.development +++ b/.env.development @@ -8,7 +8,10 @@ # ---------- 租户 / 模板 ---------- # 本地开发用固定租户,生产环境通过域名自动识别租户 NUXT_PUBLIC_TENANT_ID=10626 -NUXT_PUBLIC_APP_ID=website +# 应用产品 ID(app_product.product_id 数字主键,种子数据为 42)。 +# 注意:不是 productCode('website'),按编码查询会打到 +# /api/app/product/detail/{productId} 导致 Integer 转换失败。 +NUXT_PUBLIC_APP_ID=42 NUXT_PUBLIC_TEMPLATE_ID=template-07 # 本地调试用:强制指定模板,优先级高于应用/站点绑定。留空表示不覆盖 diff --git a/.env.example b/.env.example index 38c4f1e..a64d18e 100644 --- a/.env.example +++ b/.env.example @@ -16,16 +16,19 @@ # ---------- 租户 / 应用 / 模板 ---------- # 固定租户 ID。生产环境留空,改由域名自动识别 NUXT_PUBLIC_TENANT_ID= -NUXT_PUBLIC_APP_ID=website +# 应用产品 ID(app_product.product_id 数字主键,如 42)。 +# 注意:不是 productCode('website'),按编码查询会打到 +# /api/app/product/detail/{productId} 导致 Integer 转换失败。 +NUXT_PUBLIC_APP_ID= # 默认模板 ID NUXT_PUBLIC_TEMPLATE_ID= # 强制模板 ID:设置后优先级最高,覆盖应用/站点绑定(仅本地调试用) NUXT_PUBLIC_FORCE_TEMPLATE_ID= # ---------- 后端接口地址 ---------- -# 内容/CMS 接口。生产为 https://hjc-api.websoft.top/api +# 内容/CMS 接口。生产为 https://hjc-api.sitelnk.cn/api NUXT_PUBLIC_MODULES_API_BASE= -# 租户/模板解析接口。生产为 https://hjc-api.websoft.top(无 /api 后缀) +# 租户/模板解析接口。生产为 https://hjc-api.sitelnk.cn(无 /api 后缀) NUXT_PUBLIC_APP_API_BASE= # 图片/文件服务器。留空则从 APP_API_BASE 自动推导 NUXT_PUBLIC_FILE_SERVER_BASE= diff --git a/.env.production b/.env.production index 98b29fa..58032d7 100644 --- a/.env.production +++ b/.env.production @@ -7,10 +7,10 @@ # ---------- 接口(生产必须指向线上) ---------- # 单一后端:内容/CMS、租户解析、模板解析全部走 hjc-api -NUXT_PUBLIC_MODULES_API_BASE=https://hjc-api.websoft.top/api -NUXT_PUBLIC_APP_API_BASE=https://hjc-api.websoft.top +NUXT_PUBLIC_MODULES_API_BASE=https://hjc-api.sitelnk.cn/api +NUXT_PUBLIC_APP_API_BASE=https://hjc-api.sitelnk.cn # 图片/文件服务器。留空则由 nuxt.config.ts 从 APP_API_BASE 推导出 -# https://hjc-api.websoft.top,无需手写 +# https://hjc-api.sitelnk.cn,无需手写 NUXT_PUBLIC_FILE_SERVER_BASE= # ---------- 站点 / 多租户域名 ---------- diff --git a/.workbuddy/memory/2026-09-08.md b/.workbuddy/memory/2026-09-08.md index 7d334e6..35ec9ce 100644 --- a/.workbuddy/memory/2026-09-08.md +++ b/.workbuddy/memory/2026-09-08.md @@ -1,8 +1,8 @@ -# 2026-09-08 - -## 本地开发接口切换(cms-api → 127.0.0.1:9500) -- 需求:把 CMS/内容接口从 `https://hjc-api.websoft.top/api` 切到本地 `http://127.0.0.1:9500/api`。 -- 改法:只动 `.env`,未改 `nuxt.config.ts` 默认值(保持生产配置不变)。新增 `NUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9500/api`;NUXT_ 前缀环境变量会覆盖 `runtimeConfig.public.modulesApiBase`。 -- 关联:`modulesApiBase` 被 server/api 下 site/info、page/*、article/*、product/*、case/*、banner、form/submit、sitemap、domain/resolve 共用;文件/图片走另一个变量 `fileServerBase`(默认 origin(serverApiBase)=server.websoft.top),已在 .env 留注释开关。 -- 顺带修复:原 `.env` 中 `NUXT_PUBLIC_TENANT_ID` / `NUXT_PUBLIC_TEMPLATE_ID` 两行有前导空格,dotenv 无法解析 → 配置一直没生效。已去除空格(tenant 10626 / template-07 将真正生效,若之前靠默认 template-01 调试需注意行为变化)。 -- 验证:9500 端口有服务(/api 返回 404,端口通);改动后需重启 `pnpm dev` 才生效。 +# 2026-09-08 + +## 本地开发接口切换(cms-api → 127.0.0.1:9500) +- 需求:把 CMS/内容接口从 `https://hjc-api.sitelnk.cn/api` 切到本地 `http://127.0.0.1:9500/api`。 +- 改法:只动 `.env`,未改 `nuxt.config.ts` 默认值(保持生产配置不变)。新增 `NUXT_PUBLIC_MODULES_API_BASE=http://127.0.0.1:9500/api`;NUXT_ 前缀环境变量会覆盖 `runtimeConfig.public.modulesApiBase`。 +- 关联:`modulesApiBase` 被 server/api 下 site/info、page/*、article/*、product/*、case/*、banner、form/submit、sitemap、domain/resolve 共用;文件/图片走另一个变量 `fileServerBase`(默认 origin(serverApiBase)=server.websoft.top),已在 .env 留注释开关。 +- 顺带修复:原 `.env` 中 `NUXT_PUBLIC_TENANT_ID` / `NUXT_PUBLIC_TEMPLATE_ID` 两行有前导空格,dotenv 无法解析 → 配置一直没生效。已去除空格(tenant 10626 / template-07 将真正生效,若之前靠默认 template-01 调试需注意行为变化)。 +- 验证:9500 端口有服务(/api 返回 404,端口通);改动后需重启 `pnpm dev` 才生效。 diff --git a/README.md b/README.md index 4d4974f..de80852 100644 --- a/README.md +++ b/README.md @@ -98,18 +98,19 @@ grep -rl "127.0.0.1" .output/ || echo "产物干净,无本地地址" ```bash # 租户 / 模板(生产留空,由域名自动识别) NUXT_PUBLIC_TENANT_ID= -NUXT_PUBLIC_APP_ID=website +# 应用产品 ID(app_product.product_id 数字主键,如 42;不是 productCode 'website') +NUXT_PUBLIC_APP_ID=42 NUXT_PUBLIC_TEMPLATE_ID= # 接口地址(单一后端:内容/CMS、租户解析、模板解析全部走 hjc-api) -NUXT_PUBLIC_MODULES_API_BASE=https://hjc-api.websoft.top/api -NUXT_PUBLIC_APP_API_BASE=https://hjc-api.websoft.top +NUXT_PUBLIC_MODULES_API_BASE=https://hjc-api.sitelnk.cn/api +NUXT_PUBLIC_APP_API_BASE=https://hjc-api.sitelnk.cn # 多租户域名 NUXT_PUBLIC_BASE_DOMAINS=shoplnk.cn,sitelink.cn,wsdns.cn ``` -> ℹ️ `nuxt.config.ts` 内置的接口默认值已统一为 `https://hjc-api.websoft.top`(单后端)。 +> ℹ️ `nuxt.config.ts` 内置的接口默认值已统一为 `https://hjc-api.sitelnk.cn`(单后端)。 > `.env.production` 中也显式指定同一地址。 > 若将来再次更换域名,请同步修改 `nuxt.config.ts`、`.env.production` 与 `.env.example`。 diff --git a/database/template-switch-verification.md b/database/template-switch-verification.md index cc65fe7..8ddb064 100644 --- a/database/template-switch-verification.md +++ b/database/template-switch-verification.md @@ -1,70 +1,70 @@ -# 模板切换功能闭环 — 改动与验证清单 - -> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。 -> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。 - -## 〇、架构路由(关键:写/读分属两个后端,共享 Redis) -- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`: - 后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。 - `CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。 -- **读路径** `GET /cms/cms-website/getSiteInfo`: - 公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.websoft.top` = **mp-java**。 - 返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。 -- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码), - 故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。 - -## 一、已实施的代码改动 -### 读路径(mp-java / cms-api,必须部署) -**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段 -```java -@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入") -private Integer templateId; -``` -> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。 - -**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段 -```java -// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX -vo.setTemplateId(website.getTemplateId()); -``` - -### 写路径(websopy-java / websopy-api) -**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存 -```java -website.setTemplateId(templateId); -if (cmsWebsiteService.updateById(website)) { - // 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板 - // (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis) - cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId()); - return success("选用成功"); -} -``` -> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。 - -## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用) -迁移脚本:`website-template/database/migrate-cms-website-template-id.sql` -作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。 -核查: -```sql -SHOW COLUMNS FROM cms_website LIKE 'template_id'; -SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id'; -``` -存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。 - -## 三、前端改动(website-template,已加临时日志) -- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。** -- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。 - -## 四、端到端验证 -1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。 -2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。 -3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。 -4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见 - `[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。 -5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。 -6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。 -7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。 - -## 五、回滚 -A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。 -需回滚时各自 `git revert` 对应提交即可。 +# 模板切换功能闭环 — 改动与验证清单 + +> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。 +> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。 + +## 〇、架构路由(关键:写/读分属两个后端,共享 Redis) +- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`: + 后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。 + `CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。 +- **读路径** `GET /cms/cms-website/getSiteInfo`: + 公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。 + 返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。 +- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码), + 故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。 + +## 一、已实施的代码改动 +### 读路径(mp-java / cms-api,必须部署) +**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段 +```java +@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入") +private Integer templateId; +``` +> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。 + +**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段 +```java +// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX +vo.setTemplateId(website.getTemplateId()); +``` + +### 写路径(websopy-java / websopy-api) +**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存 +```java +website.setTemplateId(templateId); +if (cmsWebsiteService.updateById(website)) { + // 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板 + // (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis) + cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId()); + return success("选用成功"); +} +``` +> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。 + +## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用) +迁移脚本:`website-template/database/migrate-cms-website-template-id.sql` +作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。 +核查: +```sql +SHOW COLUMNS FROM cms_website LIKE 'template_id'; +SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id'; +``` +存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。 + +## 三、前端改动(website-template,已加临时日志) +- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。** +- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。 + +## 四、端到端验证 +1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。 +2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。 +3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。 +4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见 + `[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。 +5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。 +6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。 +7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。 + +## 五、回滚 +A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。 +需回滚时各自 `git revert` 对应提交即可。 diff --git a/nuxt.config.ts b/nuxt.config.ts index 73e9e74..6493533 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -16,7 +16,10 @@ function toBool(v?: string): boolean | null { } const tenantId = process.env.NUXT_PUBLIC_TENANT_ID || '' -const appId = process.env.NUXT_PUBLIC_APP_ID || 'website' +// appId 是 app_product.product_id 数字主键(如 42),不是 productCode('website')。 +// 留空时下游 getSubscriptionStatus 等按空值降级,绝不回退到编码值, +// 否则 /api/app/product/detail/{productId} 会因 Integer 转换失败报错。 +const appId = process.env.NUXT_PUBLIC_APP_ID || '' const templateId = process.env.NUXT_PUBLIC_TEMPLATE_ID || 'template-01' // 强制模板 ID:一旦设置则拥有最高优先级,覆盖应用/站点绑定(主要用于本地调试) const forceTemplateId = process.env.NUXT_PUBLIC_FORCE_TEMPLATE_ID || '' @@ -25,14 +28,14 @@ const siteName = process.env.NUXT_PUBLIC_SITE_NAME || '' const modulesApiBase = process.env.NUXT_PUBLIC_MODULES_API_BASE || - 'https://hjc-api.websoft.top/api' + 'https://hjc-api.sitelnk.cn/api' const appApiBase = process.env.NUXT_PUBLIC_APP_API_BASE || - 'https://hjc-api.websoft.top' + 'https://hjc-api.sitelnk.cn' const fileServerBase = process.env.NUXT_PUBLIC_FILE_SERVER_BASE || getOrigin(appApiBase) || - 'https://hjc-api.websoft.top' + 'https://hjc-api.sitelnk.cn' const baseDomain = process.env.NUXT_PUBLIC_BASE_DOMAIN || 'shoplnk.cn' // 多主域清单:子域名租户解析支持的主域(逗号分隔)。 @@ -77,8 +80,8 @@ export default defineNuxtConfig({ link: [ { rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }, // 提前与接口/图片源站建立连接,降低首字节与图片加载延迟 - { rel: 'preconnect', href: 'https://hjc-api.websoft.top', crossorigin: '' }, - { rel: 'dns-prefetch', href: 'https://hjc-api.websoft.top' }, + { rel: 'preconnect', href: 'https://hjc-api.sitelnk.cn', crossorigin: '' }, + { rel: 'dns-prefetch', href: 'https://hjc-api.sitelnk.cn' }, { rel: 'preconnect', href: 'https://file.wsdns.cn', crossorigin: '' }, { rel: 'dns-prefetch', href: 'https://file.wsdns.cn' }, // 思源黑体(Noto Sans SC,SIL 开源许可,版权无忧)网页字体,全站统一字体 diff --git a/outputs/template-07-home-with-news.html b/outputs/template-07-home-with-news.html index a2aa7b5..2ef38ca 100644 --- a/outputs/template-07-home-with-news.html +++ b/outputs/template-07-home-with-news.html @@ -1,10 +1,10 @@ -汇吉采官网 - 汇吉采官网
首页幻灯片

四大核心业务

覆盖政府采购、工程咨询、投融资服务及 AI 数智化全链条

政府采购代理

提供招标投标、政府采购全流程代理服务,专业合规、高效透明。

工程咨询服务

工程造价、工程监理、项目管理等一站式工程咨询服务,助力项目顺利落地。

投融资服务

项目投融资策划与对接,为政府及企业提供资金解决方案与财务顾问支持。

AI 数智化

运用人工智能与大数据技术,推动政务与企业数字化转型,提升管理效能。

关于我们

广西汇吉采咨询有限公司成立于2017年,是一家专业化的全过程工程咨询服务商,业务涵盖招投标代理、政府采购代理、工程造价咨询、工程管理服务及建设工程监理等领域。目前已在柳州、崇左、北海、梧州、百色、贺州等地设立分支机构,服务网络覆盖全广西。我们始终以诚信为本,以专业立身,致力于为各级政企客户提供高效、可信赖的工程咨询解决方案。

汇吉采官网

准备好开始了吗?

立即联系我们,获取专属企业官网解决方案

\ No newline at end of file +汇吉采官网 - 汇吉采官网
首页幻灯片

四大核心业务

覆盖政府采购、工程咨询、投融资服务及 AI 数智化全链条

政府采购代理

提供招标投标、政府采购全流程代理服务,专业合规、高效透明。

工程咨询服务

工程造价、工程监理、项目管理等一站式工程咨询服务,助力项目顺利落地。

投融资服务

项目投融资策划与对接,为政府及企业提供资金解决方案与财务顾问支持。

AI 数智化

运用人工智能与大数据技术,推动政务与企业数字化转型,提升管理效能。

关于我们

广西汇吉采咨询有限公司成立于2017年,是一家专业化的全过程工程咨询服务商,业务涵盖招投标代理、政府采购代理、工程造价咨询、工程管理服务及建设工程监理等领域。目前已在柳州、崇左、北海、梧州、百色、贺州等地设立分支机构,服务网络覆盖全广西。我们始终以诚信为本,以专业立身,致力于为各级政企客户提供高效、可信赖的工程咨询解决方案。

汇吉采官网

准备好开始了吗?

立即联系我们,获取专属企业官网解决方案

\ No newline at end of file diff --git a/outputs/汇吉采-标书购买-前端清单与后端接口清单.md b/outputs/汇吉采-标书购买-前端清单与后端接口清单.md index 5f3d08b..b11bff6 100644 --- a/outputs/汇吉采-标书购买-前端清单与后端接口清单.md +++ b/outputs/汇吉采-标书购买-前端清单与后端接口清单.md @@ -1,91 +1,91 @@ -# 汇吉采「标书购买」前端清单与后端接口清单 - -> 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。 -> 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。 -> 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。 - ---- - -## 一、前端清单(website-huijicai 独立端) - -### 1.1 页面 / 路由 - -| 路由 | 文件 | 说明 | 复用/新增 | -|---|---|---|---| -| `/buy` | `app/pages/buy.vue` | 标书购买流程壳入口。**照搬 `renewal.vue` 加载 `components.BuyDocument`**,无需改 `useTemplate` 核心;`index.vue` 的 routeMap `buy` 钩子已存在 | 新增(激活入口) | -| 标书列表 | `app/templates/template-07/pages/TenderList.vue` | 列表 + 状态=onsale 筛选 + 关键词 + 分类下拉,仿 `ProductList` | 新增 | -| 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 | -| 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 | -| 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 | -| 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 | -| `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) | -| (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) | - -> 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。 - -### 1.2 composable / 状态 - -| 名称 | 职责 | 复用/新增 | -|---|---|---| -| `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 | -| `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 | -| 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 | - -### 1.3 Server 代理层(`website-huijicai/server/api/`) - -| 代理端点 | 转发目标 | 备注 | -|---|---|---| -| `server/api/tender/list.get.ts` | `tenderApiBase/tender/list` | `tenderApiBase` 可配置,默认 cms-api | -| `server/api/tender/detail.get.ts` | `tenderApiBase/tender/detail` | | -| `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 | -| `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 | -| `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 | -| `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) | -| `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 | - -> **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.websoft.top/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。 - ---- - -## 二、后端接口清单(cms-java-code,新增 `tender` 包) - -### 2.1 实体 - -| 实体 | 位置 | 字段要点 | 动作 | -|---|---|---|---| -| `Tender`(新建) | `com.gxwebsoft.tender.entity` | `id, tenantId, tenderNo`(招标编号), `title, cover, price, status`(0下架/1onsale/2ended), `startSaleTime, endSaleTime, fileId/fileUrl`(标书文件), `content, sortNumber, createTime, updateTime` | 新建 | -| `ShopUser`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/phone/email/realName/companyId/certification/tenantId`;**新增 `supplierStatus`**(0非供应商/1待认证/2已认证) | 加字段 | -| `ShopOrder`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/orderNo/payStatus/orderStatus/userId/totalPrice/tenantId` 等;标书订单用 **`type=3`** | 复用 + 扩展 type | -| `TenderOrderExt`(新建,可选) | `com.gxwebsoft.tender.entity` | `orderNo, supplierId, contactName, contactPhone, contactEmail, tenderId` | 新建(存联系人/邮箱;或扩展 `PaymentWithOrderRequest`,二选一) | - -### 2.2 接口 - -| 接口 | 方法 | 入参 | 出参 | 复用/新建 | -|---|---|---|---|---| -| 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 | -| 标书详情 | `GET /api/tender/detail` | `id` | `Tender` 详情 | 新建 | -| **创建标书订单+支付** | `POST /api/tender/order` | `tenderId`、`contactName`、`contactPhone`、`contactEmail`、`quantity`(默认1) | `orderNo`、`codeUrl`(微信扫码)、`payAmount` | **新建(编排层)**:内部①校验 `status=onsale` ②组装 `PaymentWithOrderRequest`(`type=3`,`goodsId=tenderId`,`amount=price`) ③调 `paymentService.createPaymentWithOrder(要求 loginUser)` ④落 `TenderOrderExt` ⑤返回 `codeUrl` | -| 订单状态 | `GET /api/tender/order/status` | `orderNo` | `payStatus`、`orderStatus` | 新建(包装 `GET /payment/query` 或查 `ShopOrder`) | -| 下载标书 | `GET /api/tender/order/download` | `orderNo` | 文件流 / 下载链接 | 新建:校验 `payStatus=已支付` + `loginUser=下单人` → 返回 `Tender.fileUrl`(复用 file 存储) | -| 微信异步回调 | `POST /api/payment/notify/wechat`(已有) | 微信回调报文 | 成功/失败 | **复用** `PaymentNotifyController` + `WxPayNotifyService`(置 `ShopOrder.payStatus=已支付`;标书订单 type=3 同样处理,可触发"发邮件给供应商") | -| 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) | -| 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** | - -### 2.3 必须改造 / 注意(P0 增量) - -1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。 -2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。 -3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。 -4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。 -5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。 -6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。 - ---- - -## 三、阻塞编码的待确认项 - -1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。 -2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。 -3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。 - -> 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。 +# 汇吉采「标书购买」前端清单与后端接口清单 + +> 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。 +> 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。 +> 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。 + +--- + +## 一、前端清单(website-huijicai 独立端) + +### 1.1 页面 / 路由 + +| 路由 | 文件 | 说明 | 复用/新增 | +|---|---|---|---| +| `/buy` | `app/pages/buy.vue` | 标书购买流程壳入口。**照搬 `renewal.vue` 加载 `components.BuyDocument`**,无需改 `useTemplate` 核心;`index.vue` 的 routeMap `buy` 钩子已存在 | 新增(激活入口) | +| 标书列表 | `app/templates/template-07/pages/TenderList.vue` | 列表 + 状态=onsale 筛选 + 关键词 + 分类下拉,仿 `ProductList` | 新增 | +| 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 | +| 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 | +| 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 | +| 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 | +| `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) | +| (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) | + +> 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。 + +### 1.2 composable / 状态 + +| 名称 | 职责 | 复用/新增 | +|---|---|---| +| `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 | +| `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 | +| 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 | + +### 1.3 Server 代理层(`website-huijicai/server/api/`) + +| 代理端点 | 转发目标 | 备注 | +|---|---|---| +| `server/api/tender/list.get.ts` | `tenderApiBase/tender/list` | `tenderApiBase` 可配置,默认 cms-api | +| `server/api/tender/detail.get.ts` | `tenderApiBase/tender/detail` | | +| `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 | +| `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 | +| `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 | +| `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) | +| `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 | + +> **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.sitelnk.cn/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。 + +--- + +## 二、后端接口清单(cms-java-code,新增 `tender` 包) + +### 2.1 实体 + +| 实体 | 位置 | 字段要点 | 动作 | +|---|---|---|---| +| `Tender`(新建) | `com.gxwebsoft.tender.entity` | `id, tenantId, tenderNo`(招标编号), `title, cover, price, status`(0下架/1onsale/2ended), `startSaleTime, endSaleTime, fileId/fileUrl`(标书文件), `content, sortNumber, createTime, updateTime` | 新建 | +| `ShopUser`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/phone/email/realName/companyId/certification/tenantId`;**新增 `supplierStatus`**(0非供应商/1待认证/2已认证) | 加字段 | +| `ShopOrder`(复用) | `com.gxwebsoft.shop.entity` | 已有 `type/orderNo/payStatus/orderStatus/userId/totalPrice/tenantId` 等;标书订单用 **`type=3`** | 复用 + 扩展 type | +| `TenderOrderExt`(新建,可选) | `com.gxwebsoft.tender.entity` | `orderNo, supplierId, contactName, contactPhone, contactEmail, tenderId` | 新建(存联系人/邮箱;或扩展 `PaymentWithOrderRequest`,二选一) | + +### 2.2 接口 + +| 接口 | 方法 | 入参 | 出参 | 复用/新建 | +|---|---|---|---|---| +| 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 | +| 标书详情 | `GET /api/tender/detail` | `id` | `Tender` 详情 | 新建 | +| **创建标书订单+支付** | `POST /api/tender/order` | `tenderId`、`contactName`、`contactPhone`、`contactEmail`、`quantity`(默认1) | `orderNo`、`codeUrl`(微信扫码)、`payAmount` | **新建(编排层)**:内部①校验 `status=onsale` ②组装 `PaymentWithOrderRequest`(`type=3`,`goodsId=tenderId`,`amount=price`) ③调 `paymentService.createPaymentWithOrder(要求 loginUser)` ④落 `TenderOrderExt` ⑤返回 `codeUrl` | +| 订单状态 | `GET /api/tender/order/status` | `orderNo` | `payStatus`、`orderStatus` | 新建(包装 `GET /payment/query` 或查 `ShopOrder`) | +| 下载标书 | `GET /api/tender/order/download` | `orderNo` | 文件流 / 下载链接 | 新建:校验 `payStatus=已支付` + `loginUser=下单人` → 返回 `Tender.fileUrl`(复用 file 存储) | +| 微信异步回调 | `POST /api/payment/notify/wechat`(已有) | 微信回调报文 | 成功/失败 | **复用** `PaymentNotifyController` + `WxPayNotifyService`(置 `ShopOrder.payStatus=已支付`;标书订单 type=3 同样处理,可触发"发邮件给供应商") | +| 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) | +| 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** | + +### 2.3 必须改造 / 注意(P0 增量) + +1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。 +2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。 +3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。 +4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。 +5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。 +6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。 + +--- + +## 三、阻塞编码的待确认项 + +1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。 +2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。 +3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。 + +> 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。 diff --git a/scripts/cms_fill_image.py b/scripts/cms_fill_image.py index 7f6b76f..c1073fd 100644 --- a/scripts/cms_fill_image.py +++ b/scripts/cms_fill_image.py @@ -1,98 +1,98 @@ -#!/usr/bin/env python3 -"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。 -用法: python3 cms_fill_image.py -mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...] -""" -import os, json, sys, subprocess, time, urllib.request, urllib.error - -TOKEN = os.environ.get("TOKEN", "") -UPLOAD = "https://server.websoft.top/api/file/upload" -CMS = "https://hjc-api.websoft.top/api" -FILE_BASE = "https://file.websoft.top/api/file/" -HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN} - - -def _retry(fn, tries=4, sleep=2): - last = None - for i in range(tries): - try: - return fn() - except Exception as e: - last = e - time.sleep(sleep * (i + 1)) - raise last - - -def upload(local_path: str) -> str: - def _do(): - out = subprocess.run( - ["curl", "-s", "-m", "60", "-X", "POST", UPLOAD, - "-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}", - "-F", f"file=@{local_path}"], - capture_output=True, text=True, - ) - try: - d = json.loads(out.stdout) - except Exception: - raise RuntimeError(f"upload non-json: {out.stdout[:200]}") - if d.get("code") != 0: - raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}") - p = (d.get("data") or {}).get("path", "").lstrip("/") - if not p: - raise RuntimeError("upload returned empty path") - return FILE_BASE + p - return _retry(_do) - - -def get_item(module: str, id_: int): - def _do(): - url = f"{CMS}/cms/{module}/{id_}" - req = urllib.request.Request(url, headers=HDR) - try: - with urllib.request.urlopen(req, timeout=30) as r: - return json.loads(r.read())["data"] - except urllib.error.HTTPError as e: - body = e.read()[:300] - raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}") - return _retry(_do) - - -def put_item(module: str, obj: dict): - def _do(): - url = f"{CMS}/cms/{module}" - req = urllib.request.Request( - url, data=json.dumps(obj).encode(), - headers={**HDR, "Content-Type": "application/json"}, method="PUT", - ) - with urllib.request.urlopen(req, timeout=30) as r: - return json.loads(r.read()) - return _retry(_do) - - -def main(): - mapping = json.load(open(sys.argv[1])) - result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json" - results = [] - for it in mapping: - mod, iid, fpath = it["module"], it["id"], it["file"] - rec = {"module": mod, "id": iid} - try: - url = upload(fpath) - obj = get_item(mod, iid) - obj["image"] = url - out = put_item(mod, obj) - rec["url"] = url - rec["put_code"] = out.get("code") - rec["ok"] = out.get("code") == 0 - print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}") - except Exception as e: - rec["error"] = str(e)[:300] - print(f"[{mod} {iid}] ERROR: {e}") - results.append(rec) - time.sleep(1) - json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2) - print(f"\n=== done {len(results)} items, saved {result_path} ===") - - -if __name__ == "__main__": - main() +#!/usr/bin/env python3 +"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。 +用法: python3 cms_fill_image.py +mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...] +""" +import os, json, sys, subprocess, time, urllib.request, urllib.error + +TOKEN = os.environ.get("TOKEN", "") +UPLOAD = "https://server.websoft.top/api/file/upload" +CMS = "https://hjc-api.sitelnk.cn/api" +FILE_BASE = "https://file.websoft.top/api/file/" +HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN} + + +def _retry(fn, tries=4, sleep=2): + last = None + for i in range(tries): + try: + return fn() + except Exception as e: + last = e + time.sleep(sleep * (i + 1)) + raise last + + +def upload(local_path: str) -> str: + def _do(): + out = subprocess.run( + ["curl", "-s", "-m", "60", "-X", "POST", UPLOAD, + "-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}", + "-F", f"file=@{local_path}"], + capture_output=True, text=True, + ) + try: + d = json.loads(out.stdout) + except Exception: + raise RuntimeError(f"upload non-json: {out.stdout[:200]}") + if d.get("code") != 0: + raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}") + p = (d.get("data") or {}).get("path", "").lstrip("/") + if not p: + raise RuntimeError("upload returned empty path") + return FILE_BASE + p + return _retry(_do) + + +def get_item(module: str, id_: int): + def _do(): + url = f"{CMS}/cms/{module}/{id_}" + req = urllib.request.Request(url, headers=HDR) + try: + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read())["data"] + except urllib.error.HTTPError as e: + body = e.read()[:300] + raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}") + return _retry(_do) + + +def put_item(module: str, obj: dict): + def _do(): + url = f"{CMS}/cms/{module}" + req = urllib.request.Request( + url, data=json.dumps(obj).encode(), + headers={**HDR, "Content-Type": "application/json"}, method="PUT", + ) + with urllib.request.urlopen(req, timeout=30) as r: + return json.loads(r.read()) + return _retry(_do) + + +def main(): + mapping = json.load(open(sys.argv[1])) + result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json" + results = [] + for it in mapping: + mod, iid, fpath = it["module"], it["id"], it["file"] + rec = {"module": mod, "id": iid} + try: + url = upload(fpath) + obj = get_item(mod, iid) + obj["image"] = url + out = put_item(mod, obj) + rec["url"] = url + rec["put_code"] = out.get("code") + rec["ok"] = out.get("code") == 0 + print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}") + except Exception as e: + rec["error"] = str(e)[:300] + print(f"[{mod} {iid}] ERROR: {e}") + results.append(rec) + time.sleep(1) + json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2) + print(f"\n=== done {len(results)} items, saved {result_path} ===") + + +if __name__ == "__main__": + main() diff --git a/scripts/publish-cms-content.mjs b/scripts/publish-cms-content.mjs index f4db164..b13a5ae 100644 --- a/scripts/publish-cms-content.mjs +++ b/scripts/publish-cms-content.mjs @@ -1,141 +1,141 @@ -#!/usr/bin/env node -/** - * CMS 内容批量发布脚本 - * ============================================================================ - * 背景(2026-08-05): - * website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入 - * status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线, - * C 端 website-template(server/api/{article,product,case}/list.get.ts)只查 - * status=0,于是后台明明「已发布」,官网却一条都不显示。 - * - * 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。 - * - * 用法: - * # 1) 先空跑,只打印将要修改的内容,不写任何数据 - * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs - * - * # 2) 确认无误后真正执行 - * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs - * - * # 只处理文章 - * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs - * - * token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization - * (连同 "Bearer " 前缀一起复制即可,脚本会自动补全) - * ============================================================================ - */ - -const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.websoft.top/api' -const TOKEN_RAW = process.env.ADMIN_TOKEN || '' -const TENANT_ID = process.env.TENANT_ID || '10546' -const APPLY = process.env.APPLY === '1' -const MODULES = (process.env.MODULES || 'article,product,case') - .split(',') - .map((s) => s.trim()) - .filter(Boolean) - -/** CMS 权威语义:0=已发布 / 在售(C 端可见) */ -const PUBLISHED = 0 - -/** 各模块的接口路径与主键字段 */ -const MODULE_CONF = { - article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' }, - product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' }, - case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' } -} - -if (!TOKEN_RAW) { - console.error('❌ 缺少 ADMIN_TOKEN。') - console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs') - process.exit(1) -} - -const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}` - -const headers = { - 'Content-Type': 'application/json', - Authorization: TOKEN, - TenantId: String(TENANT_ID) -} - -async function fetchList(conf) { - const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200` - const res = await fetch(url, { headers }) - const json = await res.json().catch(() => null) - if (!json || json.code !== 0) { - throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`) - } - return json.data?.list || [] -} - -async function updateStatus(conf, id) { - const url = `${API_BASE}/cms/${conf.path}/status` - const res = await fetch(url, { - method: 'PUT', - headers, - body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED }) - }) - const text = await res.text() - let json = null - try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ } - return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) } -} - -async function run() { - console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`) - console.log('─'.repeat(70)) - - let totalPending = 0 - let totalOk = 0 - let totalFail = 0 - - for (const mod of MODULES) { - const conf = MODULE_CONF[mod] - if (!conf) { - console.log(`跳过未知模块 ${mod}`) - continue - } - - let list - try { - list = await fetchList(conf) - } catch (e) { - console.log(`\n[${mod}] 拉取失败:${e.message}`) - continue - } - - const pending = list.filter((it) => Number(it.status) !== PUBLISHED) - console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`) - totalPending += pending.length - - for (const it of pending) { - const id = it[conf.idKey] ?? it.id - const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30) - if (!APPLY) { - console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`) - continue - } - const r = await updateStatus(conf, id) - if (r.ok) { - totalOk++ - console.log(` ✓ ${id} → 已发布 | ${title}`) - } else { - totalFail++ - console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`) - } - } - } - - console.log('\n' + '─'.repeat(70)) - if (APPLY) { - console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`) - console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。') - } else { - console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`) - } -} - -run().catch((e) => { - console.error('执行出错:', e) - process.exit(1) -}) +#!/usr/bin/env node +/** + * CMS 内容批量发布脚本 + * ============================================================================ + * 背景(2026-08-05): + * website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入 + * status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线, + * C 端 website-template(server/api/{article,product,case}/list.get.ts)只查 + * status=0,于是后台明明「已发布」,官网却一条都不显示。 + * + * 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。 + * + * 用法: + * # 1) 先空跑,只打印将要修改的内容,不写任何数据 + * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs + * + * # 2) 确认无误后真正执行 + * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs + * + * # 只处理文章 + * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs + * + * token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization + * (连同 "Bearer " 前缀一起复制即可,脚本会自动补全) + * ============================================================================ + */ + +const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.sitelnk.cn/api' +const TOKEN_RAW = process.env.ADMIN_TOKEN || '' +const TENANT_ID = process.env.TENANT_ID || '10546' +const APPLY = process.env.APPLY === '1' +const MODULES = (process.env.MODULES || 'article,product,case') + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + +/** CMS 权威语义:0=已发布 / 在售(C 端可见) */ +const PUBLISHED = 0 + +/** 各模块的接口路径与主键字段 */ +const MODULE_CONF = { + article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' }, + product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' }, + case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' } +} + +if (!TOKEN_RAW) { + console.error('❌ 缺少 ADMIN_TOKEN。') + console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs') + process.exit(1) +} + +const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}` + +const headers = { + 'Content-Type': 'application/json', + Authorization: TOKEN, + TenantId: String(TENANT_ID) +} + +async function fetchList(conf) { + const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200` + const res = await fetch(url, { headers }) + const json = await res.json().catch(() => null) + if (!json || json.code !== 0) { + throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`) + } + return json.data?.list || [] +} + +async function updateStatus(conf, id) { + const url = `${API_BASE}/cms/${conf.path}/status` + const res = await fetch(url, { + method: 'PUT', + headers, + body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED }) + }) + const text = await res.text() + let json = null + try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ } + return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) } +} + +async function run() { + console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`) + console.log('─'.repeat(70)) + + let totalPending = 0 + let totalOk = 0 + let totalFail = 0 + + for (const mod of MODULES) { + const conf = MODULE_CONF[mod] + if (!conf) { + console.log(`跳过未知模块 ${mod}`) + continue + } + + let list + try { + list = await fetchList(conf) + } catch (e) { + console.log(`\n[${mod}] 拉取失败:${e.message}`) + continue + } + + const pending = list.filter((it) => Number(it.status) !== PUBLISHED) + console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`) + totalPending += pending.length + + for (const it of pending) { + const id = it[conf.idKey] ?? it.id + const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30) + if (!APPLY) { + console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`) + continue + } + const r = await updateStatus(conf, id) + if (r.ok) { + totalOk++ + console.log(` ✓ ${id} → 已发布 | ${title}`) + } else { + totalFail++ + console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`) + } + } + } + + console.log('\n' + '─'.repeat(70)) + if (APPLY) { + console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`) + console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。') + } else { + console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`) + } +} + +run().catch((e) => { + console.error('执行出错:', e) + process.exit(1) +}) diff --git a/server/utils/app.ts b/server/utils/app.ts index b69e9bf..b54798e 100644 --- a/server/utils/app.ts +++ b/server/utils/app.ts @@ -1,129 +1,137 @@ -import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types' -import { $fetch } from 'ofetch' - -type RuntimeConfig = ReturnType - -/** - * 从统一响应封装中取出 data - * 后端可能返回 { code, data } 也可能直接返回对象 - * - * 关键:未找到时上游返回的是「无 data 字段的信封」, - * 例如 { "code": 0, "message": "操作成功" }。 - * 旧实现在无 data 字段时会把整个信封当结果返回(truthy), - * 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。 - * 因此:无 data 字段的信封一律视为「未找到」返回 null。 - */ -function unwrap(res: ApiEnvelope | T): T | null { - if (!res || typeof res !== 'object') return null - const obj = res as Record - // 标准信封:存在 data 字段则取 data(未找到时 data 为 null) - if ('data' in obj) { - return (obj.data as T) ?? null - } - // 信封但无 data(如 { code, message })→ 视为无结果 - if ('code' in obj || 'message' in obj) { - return null - } - // 直接返回对象(无信封包裹)的情况 - return (res as T) ?? null -} - -/** 不应暴露到前端 / SSR payload 的敏感字段 */ -const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason'] - -/** - * 脱敏:移除应用密钥等敏感字段 - */ -function sanitize(app: AppProduct | null): AppProduct | null { - if (!app) return null - const clean = { ...app } - for (const key of SENSITIVE_KEYS) { - delete clean[key] - } - return clean -} - -/** - * 按 productId 查询应用产品信息 - * 后端: GET {appApiBase}/api/app/product/detail/{productId} - * 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID) - */ -export async function getAppProductById( - productId: string | number, - config: RuntimeConfig -): Promise { - const appApiBase = config.public.appApiBase as string - if (!productId) return null - - try { - const res = await $fetch | AppProduct>( - `/api/app/product/detail/${productId}`, - { - baseURL: appApiBase, - timeout: 5000, - retry: 1 - } - ) - return sanitize(unwrap(res)) - } catch { - return null - } -} - -/** - * 按域名查询应用产品信息 - * 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain} - * 用于生产环境(根据当前访问域名解析应用) - * 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId) - */ -export async function getAppProductByDomain( - domain: string, - config: RuntimeConfig -): Promise { - const appApiBase = config.public.appApiBase as string - if (!domain) return null - - try { - const res = await $fetch | AppProduct>( - '/api/app/product/getByDomain', - { - baseURL: appApiBase, - query: { domain }, - timeout: 5000, - retry: 1 - } - ) - return sanitize(unwrap(res)) - } catch { - return null - } -} - -/** - * 按域名查询应用域名绑定(appDomain 表) - * 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain} - * 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表) - * 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息 - */ -export async function getAppDomainByDomain( - domain: string, - config: RuntimeConfig -): Promise { - const appApiBase = config.public.appApiBase as string - if (!domain) return null - - try { - const res = await $fetch | AppDomain>( - '/api/app/domain/getByDomain', - { - baseURL: appApiBase, - query: { domain }, - timeout: 5000, - retry: 1 - } - ) - return unwrap(res) - } catch { - return null - } -} +import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types' +import { $fetch } from 'ofetch' + +type RuntimeConfig = ReturnType + +/** + * 从统一响应封装中取出 data + * 后端可能返回 { code, data } 也可能直接返回对象 + * + * 关键:未找到时上游返回的是「无 data 字段的信封」, + * 例如 { "code": 0, "message": "操作成功" }。 + * 旧实现在无 data 字段时会把整个信封当结果返回(truthy), + * 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。 + * 因此:无 data 字段的信封一律视为「未找到」返回 null。 + */ +function unwrap(res: ApiEnvelope | T): T | null { + if (!res || typeof res !== 'object') return null + const obj = res as Record + // 标准信封:存在 data 字段则取 data(未找到时 data 为 null) + if ('data' in obj) { + return (obj.data as T) ?? null + } + // 信封但无 data(如 { code, message })→ 视为无结果 + if ('code' in obj || 'message' in obj) { + return null + } + // 直接返回对象(无信封包裹)的情况 + return (res as T) ?? null +} + +/** 不应暴露到前端 / SSR payload 的敏感字段 */ +const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason'] + +/** + * 脱敏:移除应用密钥等敏感字段 + */ +function sanitize(app: AppProduct | null): AppProduct | null { + if (!app) return null + const clean = { ...app } + for (const key of SENSITIVE_KEYS) { + delete clean[key] + } + return clean +} + +/** + * 按 productId 查询应用产品信息 + * 后端: GET {appApiBase}/api/app/product/detail/{productId} + * 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID) + */ +export async function getAppProductById( + productId: string | number, + config: RuntimeConfig +): Promise { + const appApiBase = config.public.appApiBase as string + if (!productId) return null + + // 防御:productId 是 app_product.product_id 数字主键。 + // 传入 productCode(如 'website')会打到 /api/app/product/detail/{productId} + // 导致后端 Integer 转换失败,且每个请求都会重复报错。直接短路返回 null。 + if (!/^\d+$/.test(String(productId))) { + console.warn('[app] getAppProductById 收到非数字 productId=%s,跳过查询', productId) + return null + } + + try { + const res = await $fetch | AppProduct>( + `/api/app/product/detail/${productId}`, + { + baseURL: appApiBase, + timeout: 5000, + retry: 1 + } + ) + return sanitize(unwrap(res)) + } catch { + return null + } +} + +/** + * 按域名查询应用产品信息 + * 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain} + * 用于生产环境(根据当前访问域名解析应用) + * 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId) + */ +export async function getAppProductByDomain( + domain: string, + config: RuntimeConfig +): Promise { + const appApiBase = config.public.appApiBase as string + if (!domain) return null + + try { + const res = await $fetch | AppProduct>( + '/api/app/product/getByDomain', + { + baseURL: appApiBase, + query: { domain }, + timeout: 5000, + retry: 1 + } + ) + return sanitize(unwrap(res)) + } catch { + return null + } +} + +/** + * 按域名查询应用域名绑定(appDomain 表) + * 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain} + * 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表) + * 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息 + */ +export async function getAppDomainByDomain( + domain: string, + config: RuntimeConfig +): Promise { + const appApiBase = config.public.appApiBase as string + if (!domain) return null + + try { + const res = await $fetch | AppDomain>( + '/api/app/domain/getByDomain', + { + baseURL: appApiBase, + query: { domain }, + timeout: 5000, + retry: 1 + } + ) + return unwrap(res) + } catch { + return null + } +}