diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c90cb03 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,36 @@ +# 统一行尾:仓库里存 LF,检出到工作区也是 LF。 +# +# 为什么加这份文件:四个仓库的行尾原本是混着存的(hjc-java/src 237 LF + 553 CRLF, +# hjc-web/app 85 LF + 173 CRLF),又没有 .gitattributes,于是任何工具把整份文件 +# 翻一次行尾,改 5 行就变成 500 行 diff,真实改动被埋掉。 +# 2026-09-29 做了一次性规范化(git add --renormalize)。 +# +# 工作区里已存在的文件不会因此被改写,下次检出才是 LF;git 比较时同样会做这个 +# 转换,所以 status 保持干净。 +* text=auto eol=lf + +# Windows 专用脚本要留在 CRLF(目前仓库里没有,留给以后) +*.bat text eol=crlf +*.cmd text eol=crlf +*.ps1 text eol=crlf + +# 二进制:不做任何行尾转换 +*.jar binary +*.skill binary +*.png binary +*.jpg binary +*.jpeg binary +*.gif binary +*.ico binary +*.webp binary +*.ttf binary +*.otf binary +*.woff binary +*.woff2 binary +*.eot binary +*.pdf binary +*.zip binary +*.xlsx binary +*.xls binary +*.mp4 binary +*.mp3 binary diff --git a/app/components/SearchResult.vue b/app/components/SearchResult.vue index fd558fa..b4fbe40 100644 --- a/app/components/SearchResult.vue +++ b/app/components/SearchResult.vue @@ -1,336 +1,336 @@ - - - + + + diff --git a/app/pages/search.vue b/app/pages/search.vue index ea6972b..7f53331 100644 --- a/app/pages/search.vue +++ b/app/pages/search.vue @@ -1,19 +1,19 @@ - - - + + + diff --git a/database/template-switch-verification.md b/database/template-switch-verification.md index 8ddb064..7005d67 100644 --- a/database/template-switch-verification.md +++ b/database/template-switch-verification.md @@ -1,70 +1,70 @@ -# 模板切换功能闭环 — 改动与验证清单 - -> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。 -> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。 - -## 〇、架构路由(关键:写/读分属两个后端,共享 Redis) -- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`: - 后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。 - `CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。 -- **读路径** `GET /cms/cms-website/getSiteInfo`: - 公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.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` 对应提交即可。 +# 模板切换功能闭环 — 改动与验证清单 + +> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。 +> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。 + +## 〇、架构路由(关键:写/读分属两个后端,共享 Redis) +- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`: + 后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。 + `CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。 +- **读路径** `GET /cms/cms-website/getSiteInfo`: + 公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。 + 返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。 +- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码), + 故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。 + +## 一、已实施的代码改动 +### 读路径(mp-java / cms-api,必须部署) +**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段 +```java +@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入") +private Integer templateId; +``` +> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。 + +**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段 +```java +// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX +vo.setTemplateId(website.getTemplateId()); +``` + +### 写路径(websopy-java / websopy-api) +**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存 +```java +website.setTemplateId(templateId); +if (cmsWebsiteService.updateById(website)) { + // 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板 + // (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis) + cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId()); + return success("选用成功"); +} +``` +> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。 + +## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用) +迁移脚本:`website-template/database/migrate-cms-website-template-id.sql` +作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。 +核查: +```sql +SHOW COLUMNS FROM cms_website LIKE 'template_id'; +SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id'; +``` +存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。 + +## 三、前端改动(website-template,已加临时日志) +- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。** +- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。 + +## 四、端到端验证 +1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。 +2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。 +3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。 +4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见 + `[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。 +5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。 +6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。 +7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。 + +## 五、回滚 +A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。 +需回滚时各自 `git revert` 对应提交即可。 diff --git a/outputs/template-07-home-with-news.html b/outputs/template-07-home-with-news.html index 2ef38ca..5d2d1ac 100644 --- a/outputs/template-07-home-with-news.html +++ b/outputs/template-07-home-with-news.html @@ -1,10 +1,10 @@ -汇吉采官网 - 汇吉采官网
首页幻灯片

四大核心业务

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

政府采购代理

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

工程咨询服务

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

投融资服务

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

AI 数智化

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

关于我们

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

汇吉采官网

准备好开始了吗?

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

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