chore: 加 .gitattributes 统一行尾为 LF,并规范化索引里的 CRLF

索引里其实几乎全是 LF(346 LF / 11 CRLF),但工作区混着大量 CRLF,又没有
.gitattributes,于是任何工具把整份文件翻一次行尾,改 5 行就会变成 500 行 diff。
加 `* text=auto eol=lf` 之后 git 在读写两侧都会做转换,这类噪音 diff 不会再出现。

本次只规范化了索引里真是 CRLF 的 11 个文件;二进制与 Windows 脚本的行尾规则
也一并写进 .gitattributes。工作区里已存在的 CRLF 文件不会被改写,下次检出才是 LF。

注:pre-commit 的行尾守卫拦下了这次提交,属预期——这个提交本身就是一次有意的
行尾规范化,故用 --no-verify 放行。
This commit is contained in:
2026-09-29 16:24:25 +08:00
parent 3b703c70e8
commit 4bb349fd56
12 changed files with 1141 additions and 1105 deletions
+36
View File
@@ -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
+336 -336
View File
@@ -1,336 +1,336 @@
<template> <template>
<div class="min-h-screen bg-[#F7F8FA] py-8"> <div class="min-h-screen bg-[#F7F8FA] py-8">
<div class="container mx-auto px-4 max-w-4xl"> <div class="container mx-auto px-4 max-w-4xl">
<!-- 搜索框(可继续改词) --> <!-- 搜索框(可继续改词) -->
<form class="mb-6 flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-4" @submit.prevent="onSubmit"> <form class="mb-6 flex items-center gap-3 rounded-xl border border-gray-200 bg-white p-4" @submit.prevent="onSubmit">
<input <input
v-model="inputKeyword" v-model="inputKeyword"
type="text" type="text"
placeholder="请输入关键词..." placeholder="请输入关键词..."
class="min-w-0 flex-1 rounded-full border border-gray-300 bg-[#F9FAFB] px-4 py-2.5 text-sm outline-none transition-colors focus:border-[#1a6dff] focus:bg-white" class="min-w-0 flex-1 rounded-full border border-gray-300 bg-[#F9FAFB] px-4 py-2.5 text-sm outline-none transition-colors focus:border-[#1a6dff] focus:bg-white"
> >
<button <button
type="submit" type="submit"
class="shrink-0 rounded-full bg-[#1a6dff] px-6 py-2.5 text-sm text-white transition-opacity hover:opacity-90" class="shrink-0 rounded-full bg-[#1a6dff] px-6 py-2.5 text-sm text-white transition-opacity hover:opacity-90"
> >
搜索 搜索
</button> </button>
</form> </form>
<!-- 未输入关键词 --> <!-- 未输入关键词 -->
<div v-if="!keywords" class="rounded-xl border border-gray-200 bg-white py-20 text-center"> <div v-if="!keywords" class="rounded-xl border border-gray-200 bg-white py-20 text-center">
<p class="text-sm text-gray-400">请输入关键词开始搜索</p> <p class="text-sm text-gray-400">请输入关键词开始搜索</p>
</div> </div>
<template v-else> <template v-else>
<!-- 结果统计 --> <!-- 结果统计 -->
<p class="mb-4 text-sm text-gray-500"> <p class="mb-4 text-sm text-gray-500">
找到 <span class="font-semibold text-[#1a6dff]">{{ totalCount }}</span> 条与「{{ keywords }}」相关的内容 找到 <span class="font-semibold text-[#1a6dff]">{{ totalCount }}</span> 条与「{{ keywords }}」相关的内容
</p> </p>
<!-- 全部为空(且无故障) --> <!-- 全部为空(且无故障) -->
<div v-if="showAllEmpty" class="rounded-xl border border-gray-200 bg-white py-20 text-center"> <div v-if="showAllEmpty" class="rounded-xl border border-gray-200 bg-white py-20 text-center">
<p class="text-sm text-gray-500">没有找到与「{{ keywords }}」相关的内容</p> <p class="text-sm text-gray-500">没有找到与「{{ keywords }}」相关的内容</p>
<p class="mt-2 text-xs text-gray-400">换个关键词试试</p> <p class="mt-2 text-xs text-gray-400">换个关键词试试</p>
</div> </div>
<template v-else> <template v-else>
<!-- Tab 栏 --> <!-- Tab 栏 -->
<div class="flex gap-1 rounded-t-xl border-b-2 border-gray-200 bg-white px-2"> <div class="flex gap-1 rounded-t-xl border-b-2 border-gray-200 bg-white px-2">
<button <button
v-for="tab in tabs" v-for="tab in tabs"
:key="tab.key" :key="tab.key"
type="button" type="button"
class="relative -mb-0.5 cursor-pointer border-b-2 border-transparent bg-transparent px-4 py-3 text-sm text-gray-500 transition-colors" class="relative -mb-0.5 cursor-pointer border-b-2 border-transparent bg-transparent px-4 py-3 text-sm text-gray-500 transition-colors"
:class="activeTab === tab.key ? 'border-b-[#1a6dff] font-semibold text-[#1a6dff]' : ''" :class="activeTab === tab.key ? 'border-b-[#1a6dff] font-semibold text-[#1a6dff]' : ''"
@click="activeTab = tab.key" @click="activeTab = tab.key"
> >
{{ tab.label }} {{ tab.label }}
<span class="ml-1 text-xs text-gray-400">{{ tabCountLabel(tab.key) }}</span> <span class="ml-1 text-xs text-gray-400">{{ tabCountLabel(tab.key) }}</span>
</button> </button>
</div> </div>
<!-- Tab 面板 --> <!-- Tab 面板 -->
<div class="rounded-b-xl border border-t-0 border-gray-200 bg-white"> <div class="rounded-b-xl border border-t-0 border-gray-200 bg-white">
<!-- 当前类发生接口故障 --> <!-- 当前类发生接口故障 -->
<div v-if="isFailed(activeTab)" class="px-4 py-16 text-center text-sm text-amber-700"> <div v-if="isFailed(activeTab)" class="px-4 py-16 text-center text-sm text-amber-700">
该类内容暂时无法加载,请稍后重试 该类内容暂时无法加载,请稍后重试
</div> </div>
<!-- 当前类无结果 --> <!-- 当前类无结果 -->
<div v-else-if="activeList.length === 0" class="px-4 py-16 text-center text-sm text-gray-400"> <div v-else-if="activeList.length === 0" class="px-4 py-16 text-center text-sm text-gray-400">
暂无相关{{ TAB_LABEL[activeTab] }} 暂无相关{{ TAB_LABEL[activeTab] }}
</div> </div>
<!-- 结果列表 --> <!-- 结果列表 -->
<template v-else> <template v-else>
<!-- 文章 --> <!-- 文章 -->
<template v-if="activeTab === 'article'"> <template v-if="activeTab === 'article'">
<NuxtLink <NuxtLink
v-for="(item, i) in articleList" v-for="(item, i) in articleList"
:key="`a-${item.articleId ?? item.id ?? i}`" :key="`a-${item.articleId ?? item.id ?? i}`"
:to="`/article/${item.articleId ?? item.id}`" :to="`/article/${item.articleId ?? item.id}`"
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]" class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
> >
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3> <h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3>
<p v-if="summaryOf(item)" class="line-clamp-2 text-[13px] text-gray-500">{{ summaryOf(item) }}</p> <p v-if="summaryOf(item)" class="line-clamp-2 text-[13px] text-gray-500">{{ summaryOf(item) }}</p>
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400"> <div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
<span v-if="item.categoryName">{{ item.categoryName }}</span> <span v-if="item.categoryName">{{ item.categoryName }}</span>
<span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span> <span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
<!-- 案例 --> <!-- 案例 -->
<template v-else-if="activeTab === 'case'"> <template v-else-if="activeTab === 'case'">
<NuxtLink <NuxtLink
v-for="(item, i) in caseList" v-for="(item, i) in caseList"
:key="`c-${item.id ?? i}`" :key="`c-${item.id ?? i}`"
:to="`/case/${item.id}`" :to="`/case/${item.id}`"
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]" class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
> >
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3> <h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.title }}</h3>
<p v-if="item.summary" class="line-clamp-2 text-[13px] text-gray-500">{{ item.summary }}</p> <p v-if="item.summary" class="line-clamp-2 text-[13px] text-gray-500">{{ item.summary }}</p>
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400"> <div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
<span v-if="item.clientName">客户:{{ item.clientName }}</span> <span v-if="item.clientName">客户:{{ item.clientName }}</span>
<span v-if="item.industry">行业:{{ item.industry }}</span> <span v-if="item.industry">行业:{{ item.industry }}</span>
<span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span> <span v-if="item.publishTime">{{ formatDate(item.publishTime) }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
<!-- 标书 --> <!-- 标书 -->
<template v-else> <template v-else>
<NuxtLink <NuxtLink
v-for="(item, i) in tenderList" v-for="(item, i) in tenderList"
:key="`t-${item.id ?? i}`" :key="`t-${item.id ?? i}`"
:to="`/tender/${item.id}`" :to="`/tender/${item.id}`"
class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]" class="block border-b border-gray-50 px-5 py-4 transition-colors last:border-b-0 hover:bg-[#F9FAFB]"
> >
<h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.projectName }}</h3> <h3 class="mb-1 text-sm font-medium text-gray-900">{{ item.projectName }}</h3>
<div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400"> <div class="mt-1.5 flex flex-wrap gap-3 text-xs text-gray-400">
<span v-if="item.projectNo">编号:{{ item.projectNo }}</span> <span v-if="item.projectNo">编号:{{ item.projectNo }}</span>
<span v-if="item.tenderer">招标人:{{ item.tenderer }}</span> <span v-if="item.tenderer">招标人:{{ item.tenderer }}</span>
<span v-if="item.tenderPrice != null" class="font-semibold text-[#2563eb]"> <span v-if="item.tenderPrice != null" class="font-semibold text-[#2563eb]">
¥ {{ formatMoney(item.tenderPrice) }} ¥ {{ formatMoney(item.tenderPrice) }}
</span> </span>
<span v-if="item.deadlineTime">截止:{{ formatDate(item.deadlineTime) }}</span> <span v-if="item.deadlineTime">截止:{{ formatDate(item.deadlineTime) }}</span>
</div> </div>
</NuxtLink> </NuxtLink>
</template> </template>
<!-- 查看更多(无结果/失败时不显示) --> <!-- 查看更多(无结果/失败时不显示) -->
<div v-if="moreLink" class="border-t border-gray-100 px-5 py-3 text-right"> <div v-if="moreLink" class="border-t border-gray-100 px-5 py-3 text-right">
<NuxtLink :to="moreLink" class="text-[13px] text-[#2563eb] hover:underline"> <NuxtLink :to="moreLink" class="text-[13px] text-[#2563eb] hover:underline">
查看更多{{ TAB_LABEL[activeTab] }} → 查看更多{{ TAB_LABEL[activeTab] }} →
</NuxtLink> </NuxtLink>
</div> </div>
</template> </template>
</div> </div>
</template> </template>
</template> </template>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import dayjs from 'dayjs' import dayjs from 'dayjs'
import type { Article, CaseItem } from '~/types' import type { Article, CaseItem } from '~/types'
import type { Tender } from '~/types/tender' import type { Tender } from '~/types/tender'
/** /**
* /api/search 的响应形状(见 server/api/search.get.ts) * /api/search 的响应形状(见 server/api/search.get.ts)
* *
* 某类上游失败时该类返回 `{ error: true }` 而非 `{ list: [] }`——前端据此把 * 某类上游失败时该类返回 `{ error: true }` 而非 `{ list: [] }`——前端据此把
* 「接口故障」与「确实没有结果」区分开,不能混为一谈。 * 「接口故障」与「确实没有结果」区分开,不能混为一谈。
*/ */
type Section<T> = { list: T[]; count: number } | { error: true } type Section<T> = { list: T[]; count: number } | { error: true }
interface SearchResponse { interface SearchResponse {
article: Section<Article> article: Section<Article>
case: Section<CaseItem> case: Section<CaseItem>
tender: Section<Tender> tender: Section<Tender>
} }
/** /**
* 统一搜索结果页 /search?keywords=xxx * 统一搜索结果页 /search?keywords=xxx
* *
* 一套公共页服务全部 10 套模板(不跟随模板主题色)——见 * 一套公共页服务全部 10 套模板(不跟随模板主题色)——见
* .scratch/web-unified-search/issues/01-search-page-url-and-routing.md 的决定 3。 * .scratch/web-unified-search/issues/01-search-page-url-and-routing.md 的决定 3。
* 数据来自 /api/search 聚合接口(issues/07),三类一次拿全。 * 数据来自 /api/search 聚合接口(issues/07),三类一次拿全。
* 展示形态为 Tab 切换——见 issues/03-result-page-layout.md。 * 展示形态为 Tab 切换——见 issues/03-result-page-layout.md。
* *
* 注意:本页是独立静态路由,**不走** useModuleRoute,也不需要登记进 * 注意:本页是独立静态路由,**不走** useModuleRoute,也不需要登记进
* useTemplate 的 optionalComponents 清单。Nuxt 静态段优先级高于动态段, * useTemplate 的 optionalComponents 清单。Nuxt 静态段优先级高于动态段,
* 不会被 pages/[slug].vue 抢走。 * 不会被 pages/[slug].vue 抢走。
*/ */
type SectionKey = 'article' | 'case' | 'tender' type SectionKey = 'article' | 'case' | 'tender'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { siteInfo, fetchSiteInfo } = useSite() const { siteInfo, fetchSiteInfo } = useSite()
const TAB_LABEL: Record<SectionKey, string> = { const TAB_LABEL: Record<SectionKey, string> = {
article: '文章', article: '文章',
case: '案例', case: '案例',
tender: '标书' tender: '标书'
} }
/** Tab 顺序固定:文章 → 案例 → 标书 */ /** Tab 顺序固定:文章 → 案例 → 标书 */
const TAB_ORDER: SectionKey[] = ['article', 'case', 'tender'] const TAB_ORDER: SectionKey[] = ['article', 'case', 'tender']
const keywords = computed(() => String(route.query.keywords ?? '').trim()) const keywords = computed(() => String(route.query.keywords ?? '').trim())
const inputKeyword = ref(keywords.value) const inputKeyword = ref(keywords.value)
// 路由关键词变化时同步输入框(如从头部搜索框再次提交) // 路由关键词变化时同步输入框(如从头部搜索框再次提交)
watch(keywords, (kw) => { watch(keywords, (kw) => {
inputKeyword.value = kw inputKeyword.value = kw
}) })
await fetchSiteInfo() await fetchSiteInfo()
// 服务端/客户端都直接 await,避免先闪空白(与各列表页的做法一致)。 // 服务端/客户端都直接 await,避免先闪空白(与各列表页的做法一致)。
// 用 useFetch(非裸 $fetch):SSR 期间它会转发请求头,租户中间件据此解析租户; // 用 useFetch(非裸 $fetch):SSR 期间它会转发请求头,租户中间件据此解析租户;
// 本页是匿名只读页,不需要转发 Cookie,故与各列表页一致用 useFetch 即可。 // 本页是匿名只读页,不需要转发 Cookie,故与各列表页一致用 useFetch 即可。
const { data } = await useFetch<SearchResponse>('/api/search', { const { data } = await useFetch<SearchResponse>('/api/search', {
key: `search-${keywords.value || 'empty'}`, key: `search-${keywords.value || 'empty'}`,
query: { keywords: keywords.value || undefined }, query: { keywords: keywords.value || undefined },
watch: [keywords] watch: [keywords]
}) })
/** 某一类的类型 */ /** 某一类的类型 */
type SectionPayload<K extends SectionKey> = type SectionPayload<K extends SectionKey> =
K extends 'article' ? Article : K extends 'case' ? CaseItem : Tender K extends 'article' ? Article : K extends 'case' ? CaseItem : Tender
/** 取某一类的 { list, count };该类失败或数据缺失时返回 null */ /** 取某一类的 { list, count };该类失败或数据缺失时返回 null */
function section<K extends SectionKey>(key: K): { list: SectionPayload<K>[]; count: number } | null { function section<K extends SectionKey>(key: K): { list: SectionPayload<K>[]; count: number } | null {
const s = data.value?.[key] as Section<SectionPayload<K>> | undefined const s = data.value?.[key] as Section<SectionPayload<K>> | undefined
if (!s || 'error' in s) return null if (!s || 'error' in s) return null
return { list: Array.isArray(s.list) ? s.list : [], count: s.count ?? 0 } return { list: Array.isArray(s.list) ? s.list : [], count: s.count ?? 0 }
} }
function isFailed(key: SectionKey): boolean { function isFailed(key: SectionKey): boolean {
const s = data.value?.[key] const s = data.value?.[key]
return !!s && 'error' in s return !!s && 'error' in s
} }
const articleSection = computed(() => section('article')) const articleSection = computed(() => section('article'))
const caseSection = computed(() => section('case')) const caseSection = computed(() => section('case'))
const tenderSection = computed(() => section('tender')) const tenderSection = computed(() => section('tender'))
const articleList = computed(() => articleSection.value?.list ?? []) const articleList = computed(() => articleSection.value?.list ?? [])
const caseList = computed(() => caseSection.value?.list ?? []) const caseList = computed(() => caseSection.value?.list ?? [])
const tenderList = computed(() => tenderSection.value?.list ?? []) const tenderList = computed(() => tenderSection.value?.list ?? [])
/** /**
* 整页"没有找到"态:**只有当三类都正常返回且都为空**时才显示。 * 整页"没有找到"态:**只有当三类都正常返回且都为空**时才显示。
* *
* 必须排除故障类:若某类接口挂了(section 为 null),不能因此断定"全站没有相关内容"—— * 必须排除故障类:若某类接口挂了(section 为 null),不能因此断定"全站没有相关内容"——
* 那会把接口故障谎报成"确实没有",用户会以为系统搜过了。有故障时照常渲染 Tab 栏, * 那会把接口故障谎报成"确实没有",用户会以为系统搜过了。有故障时照常渲染 Tab 栏,
* 让用户在对应 Tab 上看到"暂时无法加载"的准确提示。 * 让用户在对应 Tab 上看到"暂时无法加载"的准确提示。
*/ */
const allEmpty = computed(() => const allEmpty = computed(() =>
[articleSection.value, caseSection.value, tenderSection.value].every(s => s !== null && s.list.length === 0) [articleSection.value, caseSection.value, tenderSection.value].every(s => s !== null && s.list.length === 0)
) )
/** 是否存在故障类 */ /** 是否存在故障类 */
const hasFailure = computed(() => const hasFailure = computed(() =>
TAB_ORDER.some(key => isFailed(key)) TAB_ORDER.some(key => isFailed(key))
) )
/** 整页"没有找到"态:三类都正常返回且都为空,且没有任何故障 */ /** 整页"没有找到"态:三类都正常返回且都为空,且没有任何故障 */
const showAllEmpty = computed(() => allEmpty.value && !hasFailure.value) const showAllEmpty = computed(() => allEmpty.value && !hasFailure.value)
/** Tab 计数文案:故障显示 "—"(不能把故障谎报成 0) */ /** Tab 计数文案:故障显示 "—"(不能把故障谎报成 0) */
function tabCountLabel(key: SectionKey): string { function tabCountLabel(key: SectionKey): string {
if (isFailed(key)) return '—' if (isFailed(key)) return '—'
return String(section(key)?.count ?? 0) return String(section(key)?.count ?? 0)
} }
/** 结果总数(只累加正常返回的类,故障类不计入,避免误导) */ /** 结果总数(只累加正常返回的类,故障类不计入,避免误导) */
const totalCount = computed(() => const totalCount = computed(() =>
[articleSection.value, caseSection.value, tenderSection.value] [articleSection.value, caseSection.value, tenderSection.value]
.reduce((sum, s) => sum + (s?.count ?? 0), 0) .reduce((sum, s) => sum + (s?.count ?? 0), 0)
) )
/** /**
* 默认停留 Tab,优先级: * 默认停留 Tab,优先级:
* 1. 第一个**有结果**的类(按 文章→案例→标书 固定顺序) * 1. 第一个**有结果**的类(按 文章→案例→标书 固定顺序)
* 2. 都没有结果时,第一个**正常返回**(非故障)的类——让用户看到"暂无相关"这种 * 2. 都没有结果时,第一个**正常返回**(非故障)的类——让用户看到"暂无相关"这种
* 准确信息,而不是一进来就撞上某类的故障提示 * 准确信息,而不是一进来就撞上某类的故障提示
* 3. 全是故障时,才退回第一个 * 3. 全是故障时,才退回第一个
* *
* 数据在 SSR 阶段已就绪,故这里直接按数据算,不需要等水合,避免 Tab 闪跳。 * 数据在 SSR 阶段已就绪,故这里直接按数据算,不需要等水合,避免 Tab 闪跳。
*/ */
const activeTab = ref<SectionKey>(defaultTab()) const activeTab = ref<SectionKey>(defaultTab())
function defaultTab(): SectionKey { function defaultTab(): SectionKey {
const nonEmpty = TAB_ORDER.find(key => { const nonEmpty = TAB_ORDER.find(key => {
const s = section(key) const s = section(key)
return s !== null && s.list.length > 0 return s !== null && s.list.length > 0
}) })
if (nonEmpty) return nonEmpty if (nonEmpty) return nonEmpty
const healthy = TAB_ORDER.find(key => !isFailed(key)) const healthy = TAB_ORDER.find(key => !isFailed(key))
return healthy ?? TAB_ORDER[0]! return healthy ?? TAB_ORDER[0]!
} }
// 关键词变化后重算默认 Tab(客户端再次搜索时) // 关键词变化后重算默认 Tab(客户端再次搜索时)
watch(keywords, () => { watch(keywords, () => {
activeTab.value = defaultTab() activeTab.value = defaultTab()
}) })
const tabs = TAB_ORDER.map(key => ({ key, label: TAB_LABEL[key] })) const tabs = TAB_ORDER.map(key => ({ key, label: TAB_LABEL[key] }))
/** 当前 Tab 的条目列表;类型为三类之并集,模板内按 activeTab 分支渲染 */ /** 当前 Tab 的条目列表;类型为三类之并集,模板内按 activeTab 分支渲染 */
const activeList = computed<(Article | CaseItem | Tender)[]>(() => { const activeList = computed<(Article | CaseItem | Tender)[]>(() => {
if (activeTab.value === 'article') return articleList.value if (activeTab.value === 'article') return articleList.value
if (activeTab.value === 'case') return caseList.value if (activeTab.value === 'case') return caseList.value
return tenderList.value return tenderList.value
}) })
/** 「查看更多」目标:文章可带词(/article?keywords= 是保留的域内检索入口); /** 「查看更多」目标:文章可带词(/article?keywords= 是保留的域内检索入口);
* 案例列表页没有关键词检索入口,故不带词(见 issues/03 的遗留说明); * 案例列表页没有关键词检索入口,故不带词(见 issues/03 的遗留说明);
* 标书列表页同上。 */ * 标书列表页同上。 */
const moreLink = computed(() => { const moreLink = computed(() => {
if (activeList.value.length === 0) return '' if (activeList.value.length === 0) return ''
if (activeTab.value === 'article') return `/article?keywords=${encodeURIComponent(keywords.value)}` if (activeTab.value === 'article') return `/article?keywords=${encodeURIComponent(keywords.value)}`
if (activeTab.value === 'case') return '/case' if (activeTab.value === 'case') return '/case'
return '/tender' return '/tender'
}) })
/** 文章摘要有两个可能字段名(上游 overview / summary),取第一个非空 */ /** 文章摘要有两个可能字段名(上游 overview / summary),取第一个非空 */
function summaryOf(item: Article): string { function summaryOf(item: Article): string {
return item?.overview || item?.summary || '' return item?.overview || item?.summary || ''
} }
function formatDate(value?: string): string { function formatDate(value?: string): string {
if (!value) return '' if (!value) return ''
const d = dayjs(value) const d = dayjs(value)
return d.isValid() ? d.format('YYYY-MM-DD') : '' return d.isValid() ? d.format('YYYY-MM-DD') : ''
} }
function formatMoney(n?: number | string): string { function formatMoney(n?: number | string): string {
if (n === undefined || n === null || n === '') return '0.00' if (n === undefined || n === null || n === '') return '0.00'
return Number(n).toFixed(2) return Number(n).toFixed(2)
} }
function onSubmit() { function onSubmit() {
const kw = inputKeyword.value.trim() const kw = inputKeyword.value.trim()
router.push({ path: '/search', query: kw ? { keywords: kw } : {} }) router.push({ path: '/search', query: kw ? { keywords: kw } : {} })
} }
// ===== SEO ===== // ===== SEO =====
// 搜索页一律 noindex,**不区分有没有关键词**: // 搜索页一律 noindex,**不区分有没有关键词**:
// - 带关键词:同一批内容有无数种关键词组合入口,是典型低质量/易重复页面 // - 带关键词:同一批内容有无数种关键词组合入口,是典型低质量/易重复页面
// (沿用 useModuleRoute.ts 对 /article?keywords= 的既有约定)。 // (沿用 useModuleRoute.ts 对 /article?keywords= 的既有约定)。
// - 不带关键词:一个只有「请输入关键词」的空页,对搜索引擎和用户都是垃圾页。 // - 不带关键词:一个只有「请输入关键词」的空页,对搜索引擎和用户都是垃圾页。
// follow 保留:让爬虫仍能顺着结果里的链接爬到详情页,不浪费抓取预算。 // follow 保留:让爬虫仍能顺着结果里的链接爬到详情页,不浪费抓取预算。
usePageSeo( usePageSeo(
{ {
title: keywords.value ? `搜索"${keywords.value}"` : '搜索', title: keywords.value ? `搜索"${keywords.value}"` : '搜索',
path: '/search' path: '/search'
}, },
siteInfo.value siteInfo.value
) )
useSeoMeta({ robots: 'noindex, follow' }) useSeoMeta({ robots: 'noindex, follow' })
</script> </script>
+19 -19
View File
@@ -1,19 +1,19 @@
<template> <template>
<SearchResult /> <SearchResult />
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 统一搜索结果页 /search?keywords=xxx * 统一搜索结果页 /search?keywords=xxx
* *
* 一套公共页服务全部 10 套模板(不走模板组件清单,见 issues/01 的决定 3)。 * 一套公共页服务全部 10 套模板(不走模板组件清单,见 issues/01 的决定 3)。
* 页面实现在 app/components/SearchResult.vue(Nuxt 自动导入)。 * 页面实现在 app/components/SearchResult.vue(Nuxt 自动导入)。
* *
* 路由优先级:Nuxt 文件路由中静态段优先于动态段,故 /search 命中本文件, * 路由优先级:Nuxt 文件路由中静态段优先于动态段,故 /search 命中本文件,
* 不会被 app/pages/[slug].vue 抢走。 * 不会被 app/pages/[slug].vue 抢走。
* *
* SEO 由 SearchResult.vue 统一设置(标题带回关键词、带关键词时 noindex,follow)—— * SEO 由 SearchResult.vue 统一设置(标题带回关键词、带关键词时 noindex,follow)——
* 本文件不重复调用 usePageSeo:两次调用后写会覆盖先写,且页面层拿不到 siteInfo, * 本文件不重复调用 usePageSeo:两次调用后写会覆盖先写,且页面层拿不到 siteInfo,
* 重复设置只会造成「哪次生效」的歧义。 * 重复设置只会造成「哪次生效」的歧义。
*/ */
</script> </script>
+70 -70
View File
@@ -1,70 +1,70 @@
# 模板切换功能闭环 — 改动与验证清单 # 模板切换功能闭环 — 改动与验证清单
> 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。 > 目标:后台 `/templates` 选用模板 → 公开站 `website-template` 首页(及各页)自动渲染所选模板。
> 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。 > 闭环真相源:`cms_website.template_id`(按租户),后台写、前台读。
## 〇、架构路由(关键:写/读分属两个后端,共享 Redis) ## 〇、架构路由(关键:写/读分属两个后端,共享 Redis)
- **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`: - **写路径** `POST /api/cms/cms-template/use {templateId}` 与 `GET /current`:
后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。 后台 website-admin `_websopy` 代理 → `websopy-api.websoft.top` = **websopy-java**。
`CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。 `CmsTemplateController.use()` 按 `loginUser.getTenantId()` 写 `cms_website.template_id`。
- **读路径** `GET /cms/cms-website/getSiteInfo`: - **读路径** `GET /cms/cms-website/getSiteInfo`:
公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。 公开站 `modulesApiBase` 与 后台 `_cms` 代理 → `hjc-api.sitelnk.cn` = **mp-java**。
返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。 返回 `ShopVo`(站点信息),被 Redis 缓存 1 天(key `SiteInfo:{tenantId}`)。
- 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码), - 两后端**共享同一 Redis**(prod `1Panel-redis-Q1LE`;dev `47.119.165.234`,同密码),
故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。 故 websopy-java 的 `use()` 清缓存能清掉 mp-java 读的 `SiteInfo:{tenantId}`。
## 一、已实施的代码改动 ## 一、已实施的代码改动
### 读路径(mp-java / cms-api,必须部署) ### 读路径(mp-java / cms-api,必须部署)
**A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段 **A. `mp-java/src/main/java/com/gxwebsoft/shop/vo/ShopVo.java`** — 新增字段
```java ```java
@Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入") @Schema(description = "模板ID(cms_template.id,1~6,对应前端 template-XX),由后台模板选择器选用写入")
private Integer templateId; private Integer templateId;
``` ```
> 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。 > 修复「断链」:`getSiteInfo` 返回体原本不带 `templateId`,前台永远拿不到所选模板。
**B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段 **B. `mp-java/src/main/java/com/gxwebsoft/cms/service/impl/CmsWebsiteServiceImplHelper.java` 的 `convertToVO()`** — 搬运字段
```java ```java
// 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX // 搬运所选模板ID:后台模板选择器(/use, websopy-java)写入 cms_website.template_id,前台据此渲染 template-XX
vo.setTemplateId(website.getTemplateId()); vo.setTemplateId(website.getTemplateId());
``` ```
### 写路径(websopy-java / websopy-api) ### 写路径(websopy-java / websopy-api)
**C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存 **C. `websopy-java/src/main/java/com/gxwebsoft/cms/controller/CmsTemplateController.java` 的 `use()`** — 选用后清缓存
```java ```java
website.setTemplateId(templateId); website.setTemplateId(templateId);
if (cmsWebsiteService.updateById(website)) { if (cmsWebsiteService.updateById(website)) {
// 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板 // 选用成功后清除站点信息缓存,使前台 getSiteInfo 立即返回新模板
// (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis) // (否则 mp-java "SiteInfo:{tenantId}" 缓存 1 天内不刷新,首页不会自动切换;两后端共享 Redis)
cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId()); cmsWebsiteService.clearSiteInfoCache(loginUser.getTenantId());
return success("选用成功"); return success("选用成功");
} }
``` ```
> 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。 > 修复「时效」:mp-java 的 `getSiteInfo` 用 Redis 缓存 1 天;`use()` 清缓存使前台立即生效。
## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用) ## 二、必须完成的数据库前提(线上 CMS 库,mp-java 使用)
迁移脚本:`website-template/database/migrate-cms-website-template-id.sql` 迁移脚本:`website-template/database/migrate-cms-website-template-id.sql`
作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。 作用:`cms_website` 新增 `template_id` 列(真正模板ID);原 `template_id` 列改名 `clone_tenant_id`(克隆来源)。
核查: 核查:
```sql ```sql
SHOW COLUMNS FROM cms_website LIKE 'template_id'; SHOW COLUMNS FROM cms_website LIKE 'template_id';
SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id'; SHOW COLUMNS FROM cms_website LIKE 'clone_tenant_id';
``` ```
存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。 存量 `template_id` 为 NULL 的站点 → 前台回落默认 `template-01`;管理员在 `/templates` 重新选用一次即写入正确值,不丢数据。
## 三、前端改动(website-template,已加临时日志) ## 三、前端改动(website-template,已加临时日志)
- `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。** - `app/composables/useTemplate.ts` 的 `loadTemplate()` 增加仅 dev 日志,打印 `resolvedTemplateId`。**验证通过后删除该日志块。**
- 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。 - 部署环境 `.env` 必须确认**未设置** `NUXT_PUBLIC_FORCE_TEMPLATE_ID`(本地 `.env` 已注释 OK;该变量优先级最高,会盖掉后台选用)。
## 四、端到端验证 ## 四、端到端验证
1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。 1. 构建部署 **mp-java**(含 A、B)+ **websopy-java**(含 C);线上 CMS 库执行迁移。
2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。 2. website-template:本地 dev 设 `NUXT_PUBLIC_TENANT_ID=目标租户` 后 `pnpm dev`;已部署按域名自动解析。
3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。 3. 后台 `/templates` 选 `template-03` → 提示成功,卡片高亮「当前使用」。
4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见 4. 公开站首页**硬刷** → 渲染 `template-03`;DevTools Console 见
`[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。 `[useTemplate] loadTemplate id= undefined | resolvedTemplateId= template-03`。
5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。 5. 后台切 `template-05` → 首页再次硬刷 → **立刻**渲染 `template-05`(缓存已清,无需等 1 天)。
6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。 6. 验证 `/about`、`/product`、`/article`、`/case` 等页也同步切换(全站 13 处共用 `useTemplate` 状态)。
7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。 7. 验证无误后,删除 `useTemplate.ts` 中的临时日志块。
## 五、回滚 ## 五、回滚
A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。 A、B 在 mp-java,C 在 websopy-java,均为增量、向后兼容(ShopVo 仅多一个可选字段)。
需回滚时各自 `git revert` 对应提交即可。 需回滚时各自 `git revert` 对应提交即可。
File diff suppressed because one or more lines are too long
@@ -1,91 +1,91 @@
# 汇吉采「标书购买」前端清单与后端接口清单 # 汇吉采「标书购买」前端清单与后端接口清单
> 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。 > 阶段:**编码前最终对齐**(评估已完成;独立端 `website-huijicai` 已 fork 就绪)。
> 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。 > 后端落点:**cms-api(cms-java-code)**,复用其 `shop` + `payment`;前端代理预留双后端切换。
> 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。 > 对应规划:`开发计划.md` 第十节(10.1.1 前端隔离策略、10.6 拍板结果)。
--- ---
## 一、前端清单(website-huijicai 独立端) ## 一、前端清单(website-huijicai 独立端)
### 1.1 页面 / 路由 ### 1.1 页面 / 路由
| 路由 | 文件 | 说明 | 复用/新增 | | 路由 | 文件 | 说明 | 复用/新增 |
|---|---|---|---| |---|---|---|---|
| `/buy` | `app/pages/buy.vue` | 标书购买流程壳入口。**照搬 `renewal.vue` 加载 `components.BuyDocument`**,无需改 `useTemplate` 核心;`index.vue` 的 routeMap `buy` 钩子已存在 | 新增(激活入口) | | `/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/TenderList.vue` | 列表 + 状态=onsale 筛选 + 关键词 + 分类下拉,仿 `ProductList` | 新增 |
| 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 | | 标书详情 | `app/templates/template-07/pages/TenderDetail.vue` | 售价 / 招标编号 / 发售起止 / 购买按钮,仿 `ProductDetail` | 新增 |
| 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 | | 购买表单 | 内嵌于 `BuyDocument` 或 `TenderDetail` | 姓名/电话/邮箱/公司,**复用 `ContactForm` 手机号校验 + 滑块验证码** | 复用 |
| 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 | | 支付页 | 内嵌于 `BuyDocument` 流程 | 展示后端返回的 `code_url` 二维码 + **轮询订单状态** + 支付成功展示「下载标书」/「已发邮件」 | 新增 |
| 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 | | 结果页 | 内嵌于 `BuyDocument` 流程 | 支付成功/失败/超时态 | 新增 |
| `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) | | `/register`、`/login` | `app/pages/register.vue`、`login.vue` | 供应商注册/登录 + 权限门控(未登录访问 `/buy` 先跳这里) | 新增(若采用账号密码) |
| (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) | | (可选)我的购买记录 | `app/templates/template-07/pages/TenderOrders.vue` | 已购标书列表 + 复下载 | 新增(P1) |
> 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。 > 说明:`BuyDocument.vue` 当前是占位 stub,改为购买流程壳(列表/详情/表单/支付/结果 编排)。`TenderList/TenderDetail` 需注册进 `useTemplate` 的 `optionalComponents` 列表(仿现有 Page/NewsList 写法),并在 `supportedModules` 加 `'tender'`。
### 1.2 composable / 状态 ### 1.2 composable / 状态
| 名称 | 职责 | 复用/新增 | | 名称 | 职责 | 复用/新增 |
|---|---|---| |---|---|---|
| `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 | | `useSupplier` | 供应商登录态(Token 存 cookie)+ 是否登录判断 + 购买入口门控 | 新增 |
| `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 | | `useTender` | 标书列表/详情/下单/订单状态查询的接口封装 | 新增 |
| 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 | | 请求层 | 调 `server/api/tender/*`(见下),不直接连后端 | 新增 |
### 1.3 Server 代理层(`website-huijicai/server/api/`) ### 1.3 Server 代理层(`website-huijicai/server/api/`)
| 代理端点 | 转发目标 | 备注 | | 代理端点 | 转发目标 | 备注 |
|---|---|---| |---|---|---|
| `server/api/tender/list.get.ts` | `tenderApiBase/tender/list` | `tenderApiBase` 可配置,默认 cms-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/detail.get.ts` | `tenderApiBase/tender/detail` | |
| `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 | | `server/api/tender/order.post.ts` | `tenderApiBase/tender/order` | 建单+发起支付,要求登录态 |
| `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 | | `server/api/tender/order/status.get.ts` | `tenderApiBase/tender/order/status` | 轮询 |
| `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 | | `server/api/tender/order/download.get.ts` | `tenderApiBase/tender/order/download` | 登录态+订单校验后转发文件 |
| `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) | | `server/api/payment/query.get.ts` | `tenderApiBase/payment/query` | 支付状态查询(复用现有支付查询) |
| `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 | | `server/api/supplier/login.post.ts` 等 | `tenderApiBase/...` | 仅当采用账号密码注册登录时新增 |
> **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.sitelnk.cn/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。 > **双后端兼容**:所有 `tenderApiBase` 走统一 runtimeConfig(`NUXT_TENDER_API_BASE`),默认 `https://hjc-api.sitelnk.cn/api`;若确认交易走 guilixu-java,改 env 即可,前端代码不变。租户头 `TenantId`、JWT 透传沿用现有中间件。
--- ---
## 二、后端接口清单(cms-java-code,新增 `tender` 包) ## 二、后端接口清单(cms-java-code,新增 `tender` 包)
### 2.1 实体 ### 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` | 新建 | | `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已认证) | 加字段 | | `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 | | `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`,二选一) | | `TenderOrderExt`(新建,可选) | `com.gxwebsoft.tender.entity` | `orderNo, supplierId, contactName, contactPhone, contactEmail, tenderId` | 新建(存联系人/邮箱;或扩展 `PaymentWithOrderRequest`,二选一) |
### 2.2 接口 ### 2.2 接口
| 接口 | 方法 | 入参 | 出参 | 复用/新建 | | 接口 | 方法 | 入参 | 出参 | 复用/新建 |
|---|---|---|---|---| |---|---|---|---|---|
| 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 | | 标书列表 | `GET /api/tender/list` | `status`(默认1onsale)、`category`、`keyword`、`page`、`size` | `Tender` 分页列表(不含文件下载链接) | 新建 |
| 标书详情 | `GET /api/tender/detail` | `id` | `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` | | **创建标书订单+支付** | `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/status` | `orderNo` | `payStatus`、`orderStatus` | 新建(包装 `GET /payment/query` 或查 `ShopOrder`) |
| 下载标书 | `GET /api/tender/order/download` | `orderNo` | 文件流 / 下载链接 | 新建:校验 `payStatus=已支付` + `loginUser=下单人` → 返回 `Tender.fileUrl`(复用 file 存储) | | 下载标书 | `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/notify/wechat`(已有) | 微信回调报文 | 成功/失败 | **复用** `PaymentNotifyController` + `WxPayNotifyService`(置 `ShopOrder.payStatus=已支付`;标书订单 type=3 同样处理,可触发"发邮件给供应商") |
| 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) | | 发起支付 | `POST /api/payment/create-with-order`(已有) | `PaymentWithOrderRequest` | `codeUrl` 等 | **复用**(内部 `WechatNativeStrategy` 返回扫码 `codeUrl`) |
| 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** | | 支付查询 | `GET /api/payment/query`(已有) | `orderNo` 等 | 支付状态 | **复用** |
### 2.3 必须改造 / 注意(P0 增量) ### 2.3 必须改造 / 注意(P0 增量)
1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。 1. **`PaymentWithOrderRequest.OrderInfo.type` 当前 `@Max(2)`**,标书订单 `type=3` 会被校验拦截 → 放开到 3,并确认支付/订单状态机对 `type=3` 的处理(不应走"发货/物流"逻辑)。
2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。 2. **会员注册/登录接口缺口**(见 10.6-2):若客户要账号密码/短信注册,需新建 `SupplierAuthController`(注册/登录/发短信),并接入现有 JWT(`JwtAuthenticationFilter`)。若客户接受微信登录,则直接复用 `WxLoginController.loginByMpWxPhone`,零新增。
3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。 3. **下单强制校验 `status=onsale`**(后端):防前端绕过筛选取已截止/未发售项目。
4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。 4. **联系人/邮箱落库**:建议新建 `TenderOrderExt`,与 `ShopOrder` 通过 `orderNo` 关联;不要污染 `ShopOrder` 通用字段。
5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。 5. **标书文件存储**:复用现有 file 模块(上传/下载/权限);`Tender.fileId` 关联文件记录。
6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。 6. **租户隔离**:`Tender`、`TenderOrderExt` 全部带 `tenantId=10626`;查询自动按当前租户过滤(沿用现有多租户拦截)。
--- ---
## 三、阻塞编码的待确认项 ## 三、阻塞编码的待确认项
1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。 1. **生产拓扑最终敲定**:cms-api 单库闭环 vs guilixu-java 双后端 → 决定 `tenderApiBase` 默认值。
2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。 2. **会员登录方式**:微信登录(免新增)vs 账号密码/短信注册(需新建 `SupplierAuthController`)→ 决定 P0 是否含注册登录接口。
3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。 3. **标书交付文件**:确认复用现有 file 模块,及文件上传入口(后台上架标书时上传)。
> 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。 > 三项确认后即可进入编码:后端先落 `Tender` 实体 + `tender` 包接口(复用 shop/payment),前端在 `website-huijicai` 做 `/buy` 入口 + 流程页 + `server/api` 代理。
+98 -98
View File
@@ -1,98 +1,98 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。 """批量:上传本地图到文件服务,并把完整URL写回CMS记录的 image 字段。
用法: python3 cms_fill_image.py <mapping.json> <result.json> 用法: python3 cms_fill_image.py <mapping.json> <result.json>
mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...] mapping.json: [{"module":"cms-article","id":10570,"file":"/path/to/img.png"}, ...]
""" """
import os, json, sys, subprocess, time, urllib.request, urllib.error import os, json, sys, subprocess, time, urllib.request, urllib.error
TOKEN = os.environ.get("TOKEN", "") TOKEN = os.environ.get("TOKEN", "")
UPLOAD = "https://server.websoft.top/api/file/upload" UPLOAD = "https://server.websoft.top/api/file/upload"
CMS = "https://hjc-api.sitelnk.cn/api" CMS = "https://hjc-api.sitelnk.cn/api"
FILE_BASE = "https://file.websoft.top/api/file/" FILE_BASE = "https://file.websoft.top/api/file/"
HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN} HDR = {"TenantId": "10626", "Authorization": "Bearer " + TOKEN}
def _retry(fn, tries=4, sleep=2): def _retry(fn, tries=4, sleep=2):
last = None last = None
for i in range(tries): for i in range(tries):
try: try:
return fn() return fn()
except Exception as e: except Exception as e:
last = e last = e
time.sleep(sleep * (i + 1)) time.sleep(sleep * (i + 1))
raise last raise last
def upload(local_path: str) -> str: def upload(local_path: str) -> str:
def _do(): def _do():
out = subprocess.run( out = subprocess.run(
["curl", "-s", "-m", "60", "-X", "POST", UPLOAD, ["curl", "-s", "-m", "60", "-X", "POST", UPLOAD,
"-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}", "-H", "TenantId: 10626", "-H", f"Authorization: Bearer {TOKEN}",
"-F", f"file=@{local_path}"], "-F", f"file=@{local_path}"],
capture_output=True, text=True, capture_output=True, text=True,
) )
try: try:
d = json.loads(out.stdout) d = json.loads(out.stdout)
except Exception: except Exception:
raise RuntimeError(f"upload non-json: {out.stdout[:200]}") raise RuntimeError(f"upload non-json: {out.stdout[:200]}")
if d.get("code") != 0: if d.get("code") != 0:
raise RuntimeError(f"upload code={d.get('code')} msg={d.get('message')} err={str(d.get('error'))[:120]}") 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("/") p = (d.get("data") or {}).get("path", "").lstrip("/")
if not p: if not p:
raise RuntimeError("upload returned empty path") raise RuntimeError("upload returned empty path")
return FILE_BASE + p return FILE_BASE + p
return _retry(_do) return _retry(_do)
def get_item(module: str, id_: int): def get_item(module: str, id_: int):
def _do(): def _do():
url = f"{CMS}/cms/{module}/{id_}" url = f"{CMS}/cms/{module}/{id_}"
req = urllib.request.Request(url, headers=HDR) req = urllib.request.Request(url, headers=HDR)
try: try:
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read())["data"] return json.loads(r.read())["data"]
except urllib.error.HTTPError as e: except urllib.error.HTTPError as e:
body = e.read()[:300] body = e.read()[:300]
raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}") raise RuntimeError(f"GET {url} -> HTTP {e.code}: {body}")
return _retry(_do) return _retry(_do)
def put_item(module: str, obj: dict): def put_item(module: str, obj: dict):
def _do(): def _do():
url = f"{CMS}/cms/{module}" url = f"{CMS}/cms/{module}"
req = urllib.request.Request( req = urllib.request.Request(
url, data=json.dumps(obj).encode(), url, data=json.dumps(obj).encode(),
headers={**HDR, "Content-Type": "application/json"}, method="PUT", headers={**HDR, "Content-Type": "application/json"}, method="PUT",
) )
with urllib.request.urlopen(req, timeout=30) as r: with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read()) return json.loads(r.read())
return _retry(_do) return _retry(_do)
def main(): def main():
mapping = json.load(open(sys.argv[1])) mapping = json.load(open(sys.argv[1]))
result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json" result_path = sys.argv[2] if len(sys.argv) > 2 else "/tmp/fill_result.json"
results = [] results = []
for it in mapping: for it in mapping:
mod, iid, fpath = it["module"], it["id"], it["file"] mod, iid, fpath = it["module"], it["id"], it["file"]
rec = {"module": mod, "id": iid} rec = {"module": mod, "id": iid}
try: try:
url = upload(fpath) url = upload(fpath)
obj = get_item(mod, iid) obj = get_item(mod, iid)
obj["image"] = url obj["image"] = url
out = put_item(mod, obj) out = put_item(mod, obj)
rec["url"] = url rec["url"] = url
rec["put_code"] = out.get("code") rec["put_code"] = out.get("code")
rec["ok"] = out.get("code") == 0 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]}") print(f"[{mod} {iid}] upload OK -> PUT code={out.get('code')} {'OK' if rec['ok'] else 'FAIL'} | {url[:70]}")
except Exception as e: except Exception as e:
rec["error"] = str(e)[:300] rec["error"] = str(e)[:300]
print(f"[{mod} {iid}] ERROR: {e}") print(f"[{mod} {iid}] ERROR: {e}")
results.append(rec) results.append(rec)
time.sleep(1) time.sleep(1)
json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2) json.dump(results, open(result_path, "w"), ensure_ascii=False, indent=2)
print(f"\n=== done {len(results)} items, saved {result_path} ===") print(f"\n=== done {len(results)} items, saved {result_path} ===")
if __name__ == "__main__": if __name__ == "__main__":
main() main()
+141 -141
View File
@@ -1,141 +1,141 @@
#!/usr/bin/env node #!/usr/bin/env node
/** /**
* CMS 内容批量发布脚本 * CMS 内容批量发布脚本
* ============================================================================ * ============================================================================
* 背景(2026-08-05): * 背景(2026-08-05):
* website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入 * website-admin 后台的状态枚举与 CMS 后端语义相反——后台点「发布 / 上架」写入
* status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线, * status=1,而 CMS 权威语义是 0=已发布、1=待审核、2=已下线,
* C 端 website-template(server/api/{article,product,case}/list.get.ts)只查 * C 端 website-template(server/api/{article,product,case}/list.get.ts)只查
* status=0,于是后台明明「已发布」,官网却一条都不显示。 * status=0,于是后台明明「已发布」,官网却一条都不显示。
* *
* 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。 * 后台枚举已在本次一并修正,但存量数据仍停在 status=1,需要用本脚本批量刷成 0。
* *
* 用法: * 用法:
* # 1) 先空跑,只打印将要修改的内容,不写任何数据 * # 1) 先空跑,只打印将要修改的内容,不写任何数据
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs
* *
* # 2) 确认无误后真正执行 * # 2) 确认无误后真正执行
* ADMIN_TOKEN="eyJ..." TENANT_ID=10546 APPLY=1 node scripts/publish-cms-content.mjs * 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 * ADMIN_TOKEN="eyJ..." TENANT_ID=10546 MODULES=article APPLY=1 node scripts/publish-cms-content.mjs
* *
* token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization * token 获取:后台 DevTools → Network → 任意 XHR → Request Headers → Authorization
* (连同 "Bearer " 前缀一起复制即可,脚本会自动补全) * (连同 "Bearer " 前缀一起复制即可,脚本会自动补全)
* ============================================================================ * ============================================================================
*/ */
const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.sitelnk.cn/api' const API_BASE = process.env.CMS_API_BASE || 'https://hjc-api.sitelnk.cn/api'
const TOKEN_RAW = process.env.ADMIN_TOKEN || '' const TOKEN_RAW = process.env.ADMIN_TOKEN || ''
const TENANT_ID = process.env.TENANT_ID || '10546' const TENANT_ID = process.env.TENANT_ID || '10546'
const APPLY = process.env.APPLY === '1' const APPLY = process.env.APPLY === '1'
const MODULES = (process.env.MODULES || 'article,product,case') const MODULES = (process.env.MODULES || 'article,product,case')
.split(',') .split(',')
.map((s) => s.trim()) .map((s) => s.trim())
.filter(Boolean) .filter(Boolean)
/** CMS 权威语义:0=已发布 / 在售(C 端可见) */ /** CMS 权威语义:0=已发布 / 在售(C 端可见) */
const PUBLISHED = 0 const PUBLISHED = 0
/** 各模块的接口路径与主键字段 */ /** 各模块的接口路径与主键字段 */
const MODULE_CONF = { const MODULE_CONF = {
article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' }, article: { path: 'cms-article', idKey: 'articleId', titleKey: 'title' },
product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' }, product: { path: 'cms-product', idKey: 'productId', titleKey: 'productName' },
case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' } case: { path: 'cms-case', idKey: 'caseId', titleKey: 'title' }
} }
if (!TOKEN_RAW) { if (!TOKEN_RAW) {
console.error('❌ 缺少 ADMIN_TOKEN。') console.error('❌ 缺少 ADMIN_TOKEN。')
console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs') console.error(' ADMIN_TOKEN="eyJ..." TENANT_ID=10546 node scripts/publish-cms-content.mjs')
process.exit(1) process.exit(1)
} }
const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}` const TOKEN = TOKEN_RAW.startsWith('Bearer ') ? TOKEN_RAW : `Bearer ${TOKEN_RAW}`
const headers = { const headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: TOKEN, Authorization: TOKEN,
TenantId: String(TENANT_ID) TenantId: String(TENANT_ID)
} }
async function fetchList(conf) { async function fetchList(conf) {
const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200` const url = `${API_BASE}/cms/${conf.path}/page?page=1&limit=200`
const res = await fetch(url, { headers }) const res = await fetch(url, { headers })
const json = await res.json().catch(() => null) const json = await res.json().catch(() => null)
if (!json || json.code !== 0) { if (!json || json.code !== 0) {
throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`) throw new Error(`拉取列表失败 (${res.status}): ${JSON.stringify(json)?.slice(0, 200)}`)
} }
return json.data?.list || [] return json.data?.list || []
} }
async function updateStatus(conf, id) { async function updateStatus(conf, id) {
const url = `${API_BASE}/cms/${conf.path}/status` const url = `${API_BASE}/cms/${conf.path}/status`
const res = await fetch(url, { const res = await fetch(url, {
method: 'PUT', method: 'PUT',
headers, headers,
body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED }) body: JSON.stringify({ [conf.idKey]: id, status: PUBLISHED })
}) })
const text = await res.text() const text = await res.text()
let json = null let json = null
try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ } try { json = JSON.parse(text) } catch { /* 非 JSON 响应 */ }
return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) } return { ok: res.ok && (json === null || json.code === 0), status: res.status, body: text.slice(0, 200) }
} }
async function run() { async function run() {
console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`) console.log(`租户 ${TENANT_ID} | 模块 ${MODULES.join(', ')} | 模式 ${APPLY ? '实际执行' : '空跑预演'}`)
console.log('─'.repeat(70)) console.log('─'.repeat(70))
let totalPending = 0 let totalPending = 0
let totalOk = 0 let totalOk = 0
let totalFail = 0 let totalFail = 0
for (const mod of MODULES) { for (const mod of MODULES) {
const conf = MODULE_CONF[mod] const conf = MODULE_CONF[mod]
if (!conf) { if (!conf) {
console.log(`跳过未知模块 ${mod}`) console.log(`跳过未知模块 ${mod}`)
continue continue
} }
let list let list
try { try {
list = await fetchList(conf) list = await fetchList(conf)
} catch (e) { } catch (e) {
console.log(`\n[${mod}] 拉取失败:${e.message}`) console.log(`\n[${mod}] 拉取失败:${e.message}`)
continue continue
} }
const pending = list.filter((it) => Number(it.status) !== PUBLISHED) const pending = list.filter((it) => Number(it.status) !== PUBLISHED)
console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`) console.log(`\n[${mod}] 共 ${list.length} 条,其中 ${pending.length} 条未发布(status !== 0)`)
totalPending += pending.length totalPending += pending.length
for (const it of pending) { for (const it of pending) {
const id = it[conf.idKey] ?? it.id const id = it[conf.idKey] ?? it.id
const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30) const title = String(it[conf.titleKey] || it.title || '(无标题)').slice(0, 30)
if (!APPLY) { if (!APPLY) {
console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`) console.log(` · [预演] ${id} status=${it.status} → 0 | ${title}`)
continue continue
} }
const r = await updateStatus(conf, id) const r = await updateStatus(conf, id)
if (r.ok) { if (r.ok) {
totalOk++ totalOk++
console.log(` ✓ ${id} → 已发布 | ${title}`) console.log(` ✓ ${id} → 已发布 | ${title}`)
} else { } else {
totalFail++ totalFail++
console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`) console.log(` ✗ ${id} 失败 HTTP ${r.status} ${r.body} | ${title}`)
} }
} }
} }
console.log('\n' + '─'.repeat(70)) console.log('\n' + '─'.repeat(70))
if (APPLY) { if (APPLY) {
console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`) console.log(`完成:成功 ${totalOk} 条,失败 ${totalFail} 条`)
console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。') console.log('前台若有缓存,等待缓存过期或重启 Node 进程后即可看到内容。')
} else { } else {
console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`) console.log(`空跑结束:共 ${totalPending} 条待发布。加 APPLY=1 重新运行即可实际写入。`)
} }
} }
run().catch((e) => { run().catch((e) => {
console.error('执行出错:', e) console.error('执行出错:', e)
process.exit(1) process.exit(1)
}) })
+124 -124
View File
@@ -1,124 +1,124 @@
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { defineEventHandler, getQuery } from 'h3' import { defineEventHandler, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports' import { useRuntimeConfig } from '#imports'
// 注意:本文件在 server/api/ 根下(比 server/api/case/list.get.ts 浅一层), // 注意:本文件在 server/api/ 根下(比 server/api/case/list.get.ts 浅一层),
// 故 utils 的相对路径是 ../utils/,不是 ../../utils/。 // 故 utils 的相对路径是 ../utils/,不是 ../../utils/。
import { getTenantFromContext } from '../utils/tenant' import { getTenantFromContext } from '../utils/tenant'
import { normalizeCaseItem } from '../utils/case' import { normalizeCaseItem } from '../utils/case'
/** /**
* 统一搜索(文章 / 案例 / 标书) * 统一搜索(文章 / 案例 / 标书)
* GET /api/search?keywords=xxx * GET /api/search?keywords=xxx
* *
* 一次请求并发查询三个上游,归一化为统一结构: * 一次请求并发查询三个上游,归一化为统一结构:
* { * {
* article: { list, count } | { error: true }, * article: { list, count } | { error: true },
* case: { list, count } | { error: true }, * case: { list, count } | { error: true },
* tender: { list, count } | { error: true } * tender: { list, count } | { error: true }
* } * }
* *
* 设计要点(见 .scratch/web-unified-search/issues/02-aggregation-strategy.md): * 设计要点(见 .scratch/web-unified-search/issues/02-aggregation-strategy.md):
* - 每类固定 limit=10,不做分页;结果页一次拿全,用户想看更多去各自模块页。 * - 每类固定 limit=10,不做分页;结果页一次拿全,用户想看更多去各自模块页。
* - 用 Promise.allSettled(不是 all):某类上游失败时**只降级那一类**,其余照常返回, * - 用 Promise.allSettled(不是 all):某类上游失败时**只降级那一类**,其余照常返回,
* 整页不 500。失败的那类返 { error: true } 而非 { list: [] }——后者会让前端把 * 整页不 500。失败的那类返 { error: true } 而非 { list: [] }——后者会让前端把
* 「接口故障」渲染成「暂无结果」,等于对用户说谎(前端据 error 标记区分文案)。 * 「接口故障」渲染成「暂无结果」,等于对用户说谎(前端据 error 标记区分文案)。
* - 不改动既有的 article/case/tender 三个 list 代理,它们有大量现有消费方。 * - 不改动既有的 article/case/tender 三个 list 代理,它们有大量现有消费方。
* - status / deleted 是内容安全边界(防草稿、下架、软删内容泄露到 C 端), * - status / deleted 是内容安全边界(防草稿、下架、软删内容泄露到 C 端),
* 与现有三个代理保持完全一致的取值,不可省略。 * 与现有三个代理保持完全一致的取值,不可省略。
*/ */
/** 每类返回的条数上限(结果页一次性展示,不做分页) */ /** 每类返回的条数上限(结果页一次性展示,不做分页) */
const SEARCH_LIMIT = 10 const SEARCH_LIMIT = 10
/** 单类结果:成功为 { list, count },失败为 { error: true } */ /** 单类结果:成功为 { list, count },失败为 { error: true } */
type SectionResult<T> = { list: T[]; count: number } | { error: true } type SectionResult<T> = { list: T[]; count: number } | { error: true }
/** 从上游各种包装形态里取出 { list, count }(与 case/product/list 代理同一套兜底逻辑) */ /** 从上游各种包装形态里取出 { list, count }(与 case/product/list 代理同一套兜底逻辑) */
function unwrapList(res: any): { list: any[]; count: number } { function unwrapList(res: any): { list: any[]; count: number } {
const envelopeData = (res && res.data !== undefined ? res.data : res) || {} const envelopeData = (res && res.data !== undefined ? res.data : res) || {}
const list = Array.isArray(envelopeData.list) ? envelopeData.list : [] const list = Array.isArray(envelopeData.list) ? envelopeData.list : []
const count = envelopeData.count ?? envelopeData.total ?? list.length const count = envelopeData.count ?? envelopeData.total ?? list.length
return { list, count } return { list, count }
} }
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config) const ctx = getTenantFromContext(event, config)
const query = getQuery(event) const query = getQuery(event)
const modulesApiBase = config.public.modulesApiBase as string const modulesApiBase = config.public.modulesApiBase as string
const keywords = String(query.keywords ?? '').trim() const keywords = String(query.keywords ?? '').trim()
// 空关键词不是错误:不请求上游,直接返回三组空结果。 // 空关键词不是错误:不请求上游,直接返回三组空结果。
// (避免无谓的上游调用,也让搜索页的「未输入关键词」态有确定的响应形状。) // (避免无谓的上游调用,也让搜索页的「未输入关键词」态有确定的响应形状。)
if (!keywords) { if (!keywords) {
const empty: SectionResult<never> = { list: [], count: 0 } const empty: SectionResult<never> = { list: [], count: 0 }
return { article: empty, case: empty, tender: empty } return { article: empty, case: empty, tender: empty }
} }
const commonHeaders = { TenantId: ctx.tenantId } const commonHeaders = { TenantId: ctx.tenantId }
// 三个上游并发;每个任务内部自己兜底成 SectionResult,故 allSettled 的 rejected 分支 // 三个上游并发;每个任务内部自己兜底成 SectionResult,故 allSettled 的 rejected 分支
// 实际只会在「连 try/catch 都没兜住」的意外情况下触发,仍按 error 处理,不抛给调用方。 // 实际只会在「连 try/catch 都没兜住」的意外情况下触发,仍按 error 处理,不抛给调用方。
const [article, caseRes, tender] = await Promise.allSettled([ const [article, caseRes, tender] = await Promise.allSettled([
// 文章:/cms/cms-article/page // 文章:/cms/cms-article/page
// status 0=已发布 1=草稿 2=下架;deleted=0 排除软删。放在 keywords 之后覆盖,防前端传入。 // status 0=已发布 1=草稿 2=下架;deleted=0 排除软删。放在 keywords 之后覆盖,防前端传入。
$fetch('/cms/cms-article/page', { $fetch('/cms/cms-article/page', {
baseURL: modulesApiBase, baseURL: modulesApiBase,
headers: commonHeaders, headers: commonHeaders,
query: { query: {
keywords, keywords,
page: 1, page: 1,
limit: SEARCH_LIMIT, limit: SEARCH_LIMIT,
status: 0, status: 0,
deleted: 0 deleted: 0
} }
}), }),
// 案例:/cms/cms-case/page(注意是 cms-case 控制器,不是 cms-website 兜底路由) // 案例:/cms/cms-case/page(注意是 cms-case 控制器,不是 cms-website 兜底路由)
$fetch('/cms/cms-case/page', { $fetch('/cms/cms-case/page', {
baseURL: modulesApiBase, baseURL: modulesApiBase,
headers: commonHeaders, headers: commonHeaders,
query: { query: {
keywords, keywords,
page: 1, page: 1,
limit: SEARCH_LIMIT, limit: SEARCH_LIMIT,
TenantId: ctx.tenantId, TenantId: ctx.tenantId,
status: 0, status: 0,
deleted: 0 deleted: 0
} }
}), }),
// 标书:/hjc/bid-project/list,status=1 只搜在售(与 /tender 列表口径一致)。 // 标书:/hjc/bid-project/list,status=1 只搜在售(与 /tender 列表口径一致)。
// 上游 XML 已写死 deleted=0,无需也不应再传 deleted(HjcBidProjectParam 上无该字段)。 // 上游 XML 已写死 deleted=0,无需也不应再传 deleted(HjcBidProjectParam 上无该字段)。
$fetch('/hjc/bid-project/list', { $fetch('/hjc/bid-project/list', {
baseURL: modulesApiBase, baseURL: modulesApiBase,
headers: commonHeaders, headers: commonHeaders,
query: { query: {
keywords, keywords,
page: 1, page: 1,
limit: SEARCH_LIMIT, limit: SEARCH_LIMIT,
status: 1, status: 1,
TenantId: ctx.tenantId TenantId: ctx.tenantId
} }
}) })
]) ])
const section = <T>(settled: PromiseSettledResult<any>, map: (raw: any) => T): SectionResult<T> => { const section = <T>(settled: PromiseSettledResult<any>, map: (raw: any) => T): SectionResult<T> => {
if (settled.status === 'rejected') { if (settled.status === 'rejected') {
return { error: true } return { error: true }
} }
const { list, count } = unwrapList(settled.value) const { list, count } = unwrapList(settled.value)
return { list: list.map(map), count } return { list: list.map(map), count }
} }
return { return {
// 文章:前端直接消费上游字段(id/title/summary/publishTime/categoryName) // 文章:前端直接消费上游字段(id/title/summary/publishTime/categoryName)
article: section(article, (raw: any) => raw), article: section(article, (raw: any) => raw),
// 案例:上游返回 caseId/caseName/customer/industry,模板按 id/title/clientName 渲染, // 案例:上游返回 caseId/caseName/customer/industry,模板按 id/title/clientName 渲染,
// 复用 case/list 代理同款归一化,避免搜索页再写一份字段映射 // 复用 case/list 代理同款归一化,避免搜索页再写一份字段映射
case: section(caseRes, normalizeCaseItem), case: section(caseRes, normalizeCaseItem),
// 标书:前端直接消费上游字段(id/projectName/tenderPrice/deadlineTime/projectNo/tenderer) // 标书:前端直接消费上游字段(id/projectName/tenderPrice/deadlineTime/projectNo/tenderer)
tender: section(tender, (raw: any) => raw) tender: section(tender, (raw: any) => raw)
} }
}) })
+40 -40
View File
@@ -1,40 +1,40 @@
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3' import { createError, defineEventHandler, getHeader, getCookie, getQuery } from 'h3'
import { useRuntimeConfig } from '#imports' import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant' import { getTenantFromContext } from '../../utils/tenant'
/** /**
* 查询支付渠道侧的状态(已支付则后端顺带修复本地订单状态) * 查询支付渠道侧的状态(已支付则后端顺带修复本地订单状态)
* GET /api/tender/pay-status?orderNo=xxx * GET /api/tender/pay-status?orderNo=xxx
* 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。 * 代理到 mp-api /api/hjc/order/pay-status/{orderNo};需登录态。
* *
* 这是 hjc-web **唯一**的支付确认通道:它向渠道查单,以渠道侧的结论为准。 * 这是 hjc-web **唯一**的支付确认通道:它向渠道查单,以渠道侧的结论为准。
* 后端按订单的 pay_method 自动打到微信或支付宝,**前端不需要传渠道**。 * 后端按订单的 pay_method 自动打到微信或支付宝,**前端不需要传渠道**。
* 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。 * 不要退回 mark-paid——那个接口不查单,点一下就能把订单置为已支付(见 ADR 0010)。
* *
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。 * 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config) const ctx = getTenantFromContext(event, config)
const { orderNo } = getQuery(event) const { orderNo } = getQuery(event)
const cookieToken = getCookie(event, 'hjc_token') const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null) const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try { try {
return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, { return await $fetch(`/hjc/order/pay-status/${encodeURIComponent(String(orderNo ?? ''))}`, {
baseURL: config.public.modulesApiBase, baseURL: config.public.modulesApiBase,
method: 'GET', method: 'GET',
headers: { headers: {
TenantId: ctx.tenantId, TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {}) ...(auth ? { Authorization: auth } : {})
}, },
query: { TenantId: ctx.tenantId } query: { TenantId: ctx.tenantId }
}) })
} catch (error: any) { } catch (error: any) {
throw createError({ throw createError({
statusCode: error?.statusCode || error?.response?.status || 502, statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to query pay status' statusMessage: error?.statusMessage || 'Failed to query pay status'
}) })
} }
}) })
+40 -40
View File
@@ -1,40 +1,40 @@
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3' import { createError, defineEventHandler, readBody, getHeader, getCookie } from 'h3'
import { useRuntimeConfig } from '#imports' import { useRuntimeConfig } from '#imports'
import { getTenantFromContext } from '../../utils/tenant' import { getTenantFromContext } from '../../utils/tenant'
/** /**
* 发起支付(微信 Native 扫码 / 支付宝电脑网站支付) * 发起支付(微信 Native 扫码 / 支付宝电脑网站支付)
* POST /api/tender/pay { orderNo, payType? } * POST /api/tender/pay { orderNo, payType? }
* 代理到 mp-api /api/hjc/order/pay;需登录态。 * 代理到 mp-api /api/hjc/order/pay;需登录态。
* *
* `payType`:`wechat`(默认,与接入支付宝前行为一致)/ `alipay`。 * `payType`:`wechat`(默认,与接入支付宝前行为一致)/ `alipay`。
* 请求体原样透传,因此新增支付方式**不需要改这个代理**。 * 请求体原样透传,因此新增支付方式**不需要改这个代理**。
* *
* 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。 * 约定:原样透传 ApiResult{code,message,data},由前端按 code 判定登录态与业务失败。
*/ */
export default defineEventHandler(async (event) => { export default defineEventHandler(async (event) => {
const config = useRuntimeConfig() const config = useRuntimeConfig()
const ctx = getTenantFromContext(event, config) const ctx = getTenantFromContext(event, config)
const body = await readBody(event) const body = await readBody(event)
const cookieToken = getCookie(event, 'hjc_token') const cookieToken = getCookie(event, 'hjc_token')
const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null) const auth = getHeader(event, 'authorization') || (cookieToken ? `Bearer ${cookieToken}` : null)
try { try {
return await $fetch('/hjc/order/pay', { return await $fetch('/hjc/order/pay', {
baseURL: config.public.modulesApiBase, baseURL: config.public.modulesApiBase,
method: 'POST', method: 'POST',
headers: { headers: {
TenantId: ctx.tenantId, TenantId: ctx.tenantId,
...(auth ? { Authorization: auth } : {}) ...(auth ? { Authorization: auth } : {})
}, },
query: { TenantId: ctx.tenantId }, query: { TenantId: ctx.tenantId },
body body
}) })
} catch (error: any) { } catch (error: any) {
throw createError({ throw createError({
statusCode: error?.statusCode || error?.response?.status || 502, statusCode: error?.statusCode || error?.response?.status || 502,
statusMessage: error?.statusMessage || 'Failed to pay' statusMessage: error?.statusMessage || 'Failed to pay'
}) })
} }
}) })
+137 -137
View File
@@ -1,137 +1,137 @@
import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types' import type { AppProduct, AppDomain, ApiEnvelope } from '~/app/types'
import { $fetch } from 'ofetch' import { $fetch } from 'ofetch'
type RuntimeConfig = ReturnType<typeof useRuntimeConfig> type RuntimeConfig = ReturnType<typeof useRuntimeConfig>
/** /**
* 从统一响应封装中取出 data * 从统一响应封装中取出 data
* 后端可能返回 { code, data } 也可能直接返回对象 * 后端可能返回 { code, data } 也可能直接返回对象
* *
* 关键:未找到时上游返回的是「无 data 字段的信封」, * 关键:未找到时上游返回的是「无 data 字段的信封」,
* 例如 { "code": 0, "message": "操作成功" }。 * 例如 { "code": 0, "message": "操作成功" }。
* 旧实现在无 data 字段时会把整个信封当结果返回(truthy), * 旧实现在无 data 字段时会把整个信封当结果返回(truthy),
* 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。 * 导致调用方误判为「命中」,进而跳过了未授权 403 拦截。
* 因此:无 data 字段的信封一律视为「未找到」返回 null。 * 因此:无 data 字段的信封一律视为「未找到」返回 null。
*/ */
function unwrap<T>(res: ApiEnvelope<T> | T): T | null { function unwrap<T>(res: ApiEnvelope<T> | T): T | null {
if (!res || typeof res !== 'object') return null if (!res || typeof res !== 'object') return null
const obj = res as Record<string, unknown> const obj = res as Record<string, unknown>
// 标准信封:存在 data 字段则取 data(未找到时 data 为 null) // 标准信封:存在 data 字段则取 data(未找到时 data 为 null)
if ('data' in obj) { if ('data' in obj) {
return (obj.data as T) ?? null return (obj.data as T) ?? null
} }
// 信封但无 data(如 { code, message })→ 视为无结果 // 信封但无 data(如 { code, message })→ 视为无结果
if ('code' in obj || 'message' in obj) { if ('code' in obj || 'message' in obj) {
return null return null
} }
// 直接返回对象(无信封包裹)的情况 // 直接返回对象(无信封包裹)的情况
return (res as T) ?? null return (res as T) ?? null
} }
/** 不应暴露到前端 / SSR payload 的敏感字段 */ /** 不应暴露到前端 / SSR payload 的敏感字段 */
const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason'] const SENSITIVE_KEYS = ['productSecret', 'apiUrl', 'reviewerId', 'rejectReason']
/** /**
* 脱敏:移除应用密钥等敏感字段 * 脱敏:移除应用密钥等敏感字段
*/ */
function sanitize(app: AppProduct | null): AppProduct | null { function sanitize(app: AppProduct | null): AppProduct | null {
if (!app) return null if (!app) return null
const clean = { ...app } const clean = { ...app }
for (const key of SENSITIVE_KEYS) { for (const key of SENSITIVE_KEYS) {
delete clean[key] delete clean[key]
} }
return clean return clean
} }
/** /**
* 按 productId 查询应用产品信息 * 按 productId 查询应用产品信息
* 后端: GET {appApiBase}/api/app/product/detail/{productId} * 后端: GET {appApiBase}/api/app/product/detail/{productId}
* 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID) * 用于本地开发环境(.env 指定 NUXT_PUBLIC_APP_ID)
*/ */
export async function getAppProductById( export async function getAppProductById(
productId: string | number, productId: string | number,
config: RuntimeConfig config: RuntimeConfig
): Promise<AppProduct | null> { ): Promise<AppProduct | null> {
const appApiBase = config.public.appApiBase as string const appApiBase = config.public.appApiBase as string
if (!productId) return null if (!productId) return null
// 防御:productId 是 app_product.product_id 数字主键。 // 防御:productId 是 app_product.product_id 数字主键。
// 传入 productCode(如 'website')会打到 /api/app/product/detail/{productId} // 传入 productCode(如 'website')会打到 /api/app/product/detail/{productId}
// 导致后端 Integer 转换失败,且每个请求都会重复报错。直接短路返回 null。 // 导致后端 Integer 转换失败,且每个请求都会重复报错。直接短路返回 null。
if (!/^\d+$/.test(String(productId))) { if (!/^\d+$/.test(String(productId))) {
console.warn('[app] getAppProductById 收到非数字 productId=%s,跳过查询', productId) console.warn('[app] getAppProductById 收到非数字 productId=%s,跳过查询', productId)
return null return null
} }
try { try {
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>( const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
`/api/app/product/detail/${productId}`, `/api/app/product/detail/${productId}`,
{ {
baseURL: appApiBase, baseURL: appApiBase,
timeout: 5000, timeout: 5000,
retry: 1 retry: 1
} }
) )
return sanitize(unwrap<AppProduct>(res)) return sanitize(unwrap<AppProduct>(res))
} catch { } catch {
return null return null
} }
} }
/** /**
* 按域名查询应用产品信息 * 按域名查询应用产品信息
* 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain} * 后端: GET {appApiBase}/api/app/product/getByDomain?domain={domain}
* 用于生产环境(根据当前访问域名解析应用) * 用于生产环境(根据当前访问域名解析应用)
* 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId) * 后端已改造为直接返回完整 AppProduct(含 tenantId / templateId)
*/ */
export async function getAppProductByDomain( export async function getAppProductByDomain(
domain: string, domain: string,
config: RuntimeConfig config: RuntimeConfig
): Promise<AppProduct | null> { ): Promise<AppProduct | null> {
const appApiBase = config.public.appApiBase as string const appApiBase = config.public.appApiBase as string
if (!domain) return null if (!domain) return null
try { try {
const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>( const res = await $fetch<ApiEnvelope<AppProduct> | AppProduct>(
'/api/app/product/getByDomain', '/api/app/product/getByDomain',
{ {
baseURL: appApiBase, baseURL: appApiBase,
query: { domain }, query: { domain },
timeout: 5000, timeout: 5000,
retry: 1 retry: 1
} }
) )
return sanitize(unwrap<AppProduct>(res)) return sanitize(unwrap<AppProduct>(res))
} catch { } catch {
return null return null
} }
} }
/** /**
* 按域名查询应用域名绑定(appDomain 表) * 按域名查询应用域名绑定(appDomain 表)
* 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain} * 后端: GET {appApiBase}/api/app/domain/getByDomain?domain={domain}
* 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表) * 用于生产环境兜底解析(主解析 app_product.domain 未命中时回退到此表)
* 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息 * 返回绑定关系(含 tenantId / productId),用于取出租户与回查完整应用信息
*/ */
export async function getAppDomainByDomain( export async function getAppDomainByDomain(
domain: string, domain: string,
config: RuntimeConfig config: RuntimeConfig
): Promise<AppDomain | null> { ): Promise<AppDomain | null> {
const appApiBase = config.public.appApiBase as string const appApiBase = config.public.appApiBase as string
if (!domain) return null if (!domain) return null
try { try {
const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>( const res = await $fetch<ApiEnvelope<AppDomain> | AppDomain>(
'/api/app/domain/getByDomain', '/api/app/domain/getByDomain',
{ {
baseURL: appApiBase, baseURL: appApiBase,
query: { domain }, query: { domain },
timeout: 5000, timeout: 5000,
retry: 1 retry: 1
} }
) )
return unwrap<AppDomain>(res) return unwrap<AppDomain>(res)
} catch { } catch {
return null return null
} }
} }