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:
+4
-1
@@ -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
@@ -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
@@ -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=
|
||||
|
||||
# ---------- 站点 / 多租户域名 ----------
|
||||
|
||||
@@ -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` 才生效。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user