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
+ }
+}