fix: NUXT_PUBLIC_APP_ID 改为数字主键,接口基址切正式域名

1. appId 语义修正
   NUXT_PUBLIC_APP_ID 之前填的是 productCode('website'),但下游拿它去请求
   /api/app/product/detail/{productId},后端按 Integer 解析主键,于是转换失败。
   改为 app_product.product_id 数字主键(种子数据为 42);nuxt.config.ts 的
   缺省值同步去掉 'website' 回退——留空时下游按空值降级,绝不回退到编码值。

2. 接口基址切 hjc-api.sitelnk.cn
   .env.example / .env.production / nuxt.config.ts 的缺省值与 preconnect、
   dns-prefetch 一并从 hjc-api.websoft.top 改为正式域名;scripts/ 与
   outputs/ 文档里的旧地址同步更新。

注:本提交含若干文件的 CRLF/LF 行尾归一,实际内容改动见 git diff --ignore-cr-at-eol。
This commit is contained in:
2026-09-25 05:22:58 +08:00
parent 3d3a377221
commit 4a5a8f306a
12 changed files with 582 additions and 564 deletions
+4 -1
View File
@@ -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
# 本地调试用:强制指定模板,优先级高于应用/站点绑定。留空表示不覆盖
+6 -3
View File
@@ -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=
+3 -3
View File
@@ -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=
# ---------- 站点 / 多租户域名 ----------
+8 -8
View File
@@ -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` 才生效。
+5 -4
View File
@@ -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`。
+70 -70
View File
@@ -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` 对应提交即可。
+9 -6
View File
@@ -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 开源许可,版权无忧)网页字体,全站统一字体
File diff suppressed because one or more lines are too long
@@ -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` 代理。
+98 -98
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env python3
"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。
用法: python3 cms_fill_image.py <mapping.json> <result.json>
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> <result.json>
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()
+141 -141
View File
@@ -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)
})
+137 -129
View File
@@ -1,129 +1,137 @@
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
import { $fetch } from 'ofetch'
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
/**
* 从统一响应封装中取出 data
* 后端可能返回 { code, data } 也可能直接返回对象
*
* 关键:未找到时上游返回的是「无 data 字段的信封」,
* 例如 { "code": 0, "message": "操作成功" }。
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
* 因此:无 data 字段的信封一律视为「未找到」返回 null。
*/
function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
if (!res || typeof res !== 'object') return null
const obj = res as Record<string, unknown>
// 标准信封:存在 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<AppProduct | null> {
const appApiBase = config.public.appApiBase as string
if (!productId) return null
try {
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
`/api/app/product/detail/${productId}`,
{
baseURL: appApiBase,
timeout: 5000,
retry: 1
}
)
return sanitize(unwrap<AppProduct>(res))
} catch {
return null
}
}
/**
* 按域名查询应用产品信息
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
* 用于生产环境(根据当前访问域名解析应用)
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
*/
export async function getAppProductByDomain(
domain: string,
config: RuntimeConfig
): Promise<AppProduct | null> {
const appApiBase = config.public.appApiBase as string
if (!domain) return null
try {
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
'/api/app/product/getByDomain',
{
baseURL: appApiBase,
query: { domain },
timeout: 5000,
retry: 1
}
)
return sanitize(unwrap<AppProduct>(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<AppDomain | null> {
const appApiBase = config.public.appApiBase as string
if (!domain) return null
try {
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
'/api/app/domain/getByDomain',
{
baseURL: appApiBase,
query: { domain },
timeout: 5000,
retry: 1
}
)
return unwrap<AppDomain>(res)
} catch {
return null
}
}
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
import { $fetch } from 'ofetch'
type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
/**
* 从统一响应封装中取出 data
* 后端可能返回 { code, data } 也可能直接返回对象
*
* 关键:未找到时上游返回的是「无 data 字段的信封」,
* 例如 { "code": 0, "message": "操作成功" }。
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
* 因此:无 data 字段的信封一律视为「未找到」返回 null。
*/
function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
if (!res || typeof res !== 'object') return null
const obj = res as Record<string, unknown>
// 标准信封:存在 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<AppProduct | null> {
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<ApiEnvelope<AppProduct> | AppProduct>(
`/api/app/product/detail/${productId}`,
{
baseURL: appApiBase,
timeout: 5000,
retry: 1
}
)
return sanitize(unwrap<AppProduct>(res))
} catch {
return null
}
}
/**
* 按域名查询应用产品信息
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
* 用于生产环境(根据当前访问域名解析应用)
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
*/
export async function getAppProductByDomain(
domain: string,
config: RuntimeConfig
): Promise<AppProduct | null> {
const appApiBase = config.public.appApiBase as string
if (!domain) return null
try {
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
'/api/app/product/getByDomain',
{
baseURL: appApiBase,
query: { domain },
timeout: 5000,
retry: 1
}
)
return sanitize(unwrap<AppProduct>(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<AppDomain | null> {
const appApiBase = config.public.appApiBase as string
if (!domain) return null
try {
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
'/api/app/domain/getByDomain',
{
baseURL: appApiBase,
query: { domain },
timeout: 5000,
retry: 1
}
)
return unwrap<AppDomain>(res)
} catch {
return null
}
}