fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH #8

Merged
Blizzard merged 15 commits from feat/site into main 2026-07-20 09:58:15 +00:00
5 changed files with 167 additions and 0 deletions
Showing only changes of commit b1fea23a0c - Show all commits
+2
View File
@@ -8,6 +8,7 @@ import { me, clearToken, type AuthUser } from "./api";
import { SiteLayout } from "./site/components/layout/site-layout";
import HomePage from "./site/pages/home";
import DownloadPage from "./site/pages/download";
import PricingPage from "./site/pages/pricing";
import NotFoundPage from "./site/pages/not-found";
// 后台鉴权门:只在访问 /admin 时跑 me()(官网公开、不打鉴权)。
@@ -54,6 +55,7 @@ export default function App() {
{/* 官网:根 / + /download,公开;未知路径落官网 404(保留 header/footer */}
<Route element={<SiteLayout />}>
<Route index element={<HomePage />} />
<Route path="pricing" element={<PricingPage />} />
<Route path="download" element={<DownloadPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
@@ -10,6 +10,7 @@ const NAV_ITEMS = [
{ label: '产品', to: '/', end: true },
{ label: '功能', to: '/#features' },
{ label: '架构', to: '/#architecture' },
{ label: '定价', to: '/pricing' },
]
export function SiteHeader() {
+151
View File
@@ -0,0 +1,151 @@
import { useEffect, useState } from 'react'
import { GATEWAY } from '../../api'
// 官网定价页。数据来自后台配置的「订阅套餐 / 积分包」,**不写死在前端**——
// 运营改价、上下架、调发放节奏都在管理端完成,这里跟着变,不用发版。
//
// 公开可见(/api/v1/pricing 不挂鉴权):让人在登录前就看得到价格,
// 这是购买转化的前提,也是官网存在的意义。
const MICRO = 1_000_000
const credits = (m: number) => (m / MICRO).toLocaleString('zh-CN', { maximumFractionDigits: 0 })
const yuan = (fen: number) => (fen / 100).toLocaleString('zh-CN', { minimumFractionDigits: fen % 100 === 0 ? 0 : 2 })
interface SubPlan {
id: string
name: string
price_fen: number
duration_days: number
refill_credits_micro: number
refill_interval_days: number
}
interface Pack {
id: string
name: string
credits_micro: number
price_fen: number
}
// 结账在 Web 面完成(那里有登录态、租户上下文与支付轮询)。官网只负责"看价 → 去买"
// 避免同一套支付流程在两处各实现一遍。
const CHECKOUT = '/usage'
export default function PricingPage() {
const [plans, setPlans] = useState<SubPlan[]>([])
const [packs, setPacks] = useState<Pack[]>([])
const [state, setState] = useState<'loading' | 'ready' | 'error'>('loading')
useEffect(() => {
// GATEWAY 生产构建为空串(同源相对),开发时指向 :8080——写死相对路径会打到 vite
fetch(`${GATEWAY}/api/v1/pricing`)
.then((r) => (r.ok ? r.json() : Promise.reject(new Error(String(r.status)))))
.then((d: { plans?: SubPlan[]; packs?: Pack[] }) => {
setPlans(d.plans ?? [])
setPacks(d.packs ?? [])
setState('ready')
})
.catch(() => setState('error'))
}, [])
return (
<main className="mx-auto max-w-6xl px-6 py-16 md:py-24">
<header className="max-w-2xl">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-accent">PRICING</p>
<h1 className="mt-3 text-3xl font-semibold tracking-tight text-ink md:text-4xl"></h1>
<p className="mt-4 text-sm leading-relaxed text-ink-2">
</p>
</header>
{state === 'loading' && <p className="mt-12 text-sm text-ink-3"></p>}
{state === 'error' && (
<p className="mt-12 text-sm text-ink-3">
<a href={CHECKOUT} className="ml-1 text-accent underline underline-offset-4">
</a>
</p>
)}
{state === 'ready' && (
<>
{plans.length > 0 && (
<section className="mt-12">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2">
</p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{plans.map((p) => {
const times = p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0
return (
<article
key={p.id}
className="flex flex-col rounded-2xl border border-hairline bg-surface p-6 transition-colors hover:border-hairline-strong"
>
<h3 className="text-base font-semibold text-ink">{p.name}</h3>
<div className="mt-3 flex items-baseline gap-1">
<span className="text-3xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</span>
<span className="text-sm text-ink-3">/ {p.duration_days} </span>
</div>
<ul className="mt-5 space-y-2 text-sm text-ink-2">
<li>
{p.refill_interval_days} {' '}
<b className="text-ink">{credits(p.refill_credits_micro)}</b>
</li>
<li>
{times} {' '}
<b className="text-ink">{credits(p.refill_credits_micro * times)}</b>
</li>
<li className="text-ink-3"></li>
</ul>
<a
href={CHECKOUT}
className="mt-6 inline-flex items-center justify-center rounded-lg bg-accent px-4 py-2.5 text-sm font-medium text-white transition-opacity hover:opacity-90"
>
</a>
</article>
)
})}
</div>
</section>
)}
{packs.length > 0 && (
<section className="mt-14">
<h2 className="text-lg font-semibold text-ink"></h2>
<p className="mt-1 text-sm text-ink-2"></p>
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{packs.map((p) => (
<article key={p.id} className="rounded-2xl border border-hairline bg-surface p-5">
<h3 className="text-sm font-semibold text-ink">{p.name}</h3>
<div className="mt-2 text-2xl font-semibold tracking-tight text-ink">¥{yuan(p.price_fen)}</div>
<p className="mt-1 text-sm text-ink-2">{credits(p.credits_micro)} </p>
<a
href={CHECKOUT}
className="mt-4 inline-flex text-sm font-medium text-accent underline underline-offset-4"
>
</a>
</article>
))}
</div>
</section>
)}
{plans.length === 0 && packs.length === 0 && (
<p className="mt-12 text-sm text-ink-3">线</p>
)}
<p className="mt-14 text-xs leading-relaxed text-ink-3">
</p>
</>
)}
</main>
)
}
@@ -36,6 +36,18 @@ func (h *Handler) MySubscription(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"subscription": sub, "plan": h.db.GetSubPlan(ctx, sub.PlanID)})
}
// PublicPricing: GET /api/v1/pricing —— **公开**(无需登录):官网定价页用。
// 官网要在用户登录前就能看到价格,所以这条不挂鉴权;只暴露在售项,且不含任何
// 内部字段(成本、权重、租户信息一概不出去)。
func (h *Handler) PublicPricing(c *gin.Context) {
ctx := c.Request.Context()
packs, _ := h.db.ActivePacks(ctx)
c.JSON(http.StatusOK, gin.H{
"plans": h.db.ListSubPlans(ctx, true),
"packs": packs,
})
}
// ---- 管理端 ----
// AdminSubPlans: GET /api/v1/admin/sub-plans —— 全部套餐(含下架)。
@@ -50,6 +50,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
api := r.Group("/api/v1")
{
// —— 公开:鉴权端点 / 健康 / 按 task_id 寻址的 SSE 与导出(EventSource/下载无法带 Bearer)——
api.GET("/pricing", h.PublicPricing) // 公开定价(官网未登录也要能看价,故不挂鉴权)
api.POST("/auth/register", h.Register) // 注册 + 签发 JWT
api.POST("/auth/login", h.Login) // 登录 + 签发 JWT
api.GET("/auth/me", h.Me) // 当前登录用户(无效令牌 → 401)