fix(rag): 全文索引在容器里根本没持久化 —— 补 /data 卷 + BLEVE_PATH #8
@@ -333,6 +333,59 @@ export async function payOrderStatus(orderId: string): Promise<{ order: PayOrder
|
|||||||
return { order: d.order as PayOrder, warn: d.warn };
|
return { order: d.order as PayOrder, warn: d.warn };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---- 订阅(手动购买制:买一个周期,期内每 N 天发一次积分,到期即失效)----
|
||||||
|
export interface SubPlan {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
price_fen: number;
|
||||||
|
duration_days: number; // 一个周期多少天
|
||||||
|
refill_credits_micro: number; // 每次发放的积分
|
||||||
|
refill_interval_days: number; // 每几天发一次
|
||||||
|
active: boolean;
|
||||||
|
sort: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SubRow {
|
||||||
|
id: string;
|
||||||
|
tenant_id: string;
|
||||||
|
tenant_name: string;
|
||||||
|
plan_name: string;
|
||||||
|
status: string; // active / expired
|
||||||
|
started_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
refill_seq: number; // 已发放次数
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminSubPlans(): Promise<SubPlan[]> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/sub-plans`, { headers: authHeaders() }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { plans?: SubPlan[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `sub plans failed: ${res.status}`);
|
||||||
|
return d.plans ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// refill_credits 以「积分」为单位(面向人),服务端转 micro。
|
||||||
|
export async function saveSubPlan(p: {
|
||||||
|
id?: string;
|
||||||
|
name: string;
|
||||||
|
price_fen: number;
|
||||||
|
duration_days: number;
|
||||||
|
refill_credits: number;
|
||||||
|
refill_interval_days: number;
|
||||||
|
active: boolean;
|
||||||
|
sort: number;
|
||||||
|
}): Promise<void> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/sub-plans`, { method: "PUT", headers: authHeaders(true), body: JSON.stringify(p) }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `save failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function adminSubscriptions(): Promise<SubRow[]> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/subscriptions`, { headers: authHeaders() }));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { subscriptions?: SubRow[]; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `subscriptions failed: ${res.status}`);
|
||||||
|
return d.subscriptions ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
||||||
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||||||
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { adminSubPlans, saveSubPlan, adminSubscriptions, type SubPlan, type SubRow } from "../api";
|
||||||
|
|
||||||
|
// 支付 · 订阅:套餐配置(价格/时长/发放节奏)+ 全平台订阅观测。
|
||||||
|
//
|
||||||
|
// 语义要在界面上讲清楚,否则配错了很难发现:
|
||||||
|
// - 手动购买制,**不自动续费**(微信 Native 无代扣能力),到期即失效;
|
||||||
|
// - 期内每 N 天发一次积分,发放是**累加**(不清零用户已有积分);
|
||||||
|
// - 因此一个周期实际发放次数 = floor(时长 / 间隔),配的时候直接算给人看。
|
||||||
|
const MICRO = 1_000_000;
|
||||||
|
const credits = (m: number) => (m / MICRO).toLocaleString("zh-CN", { maximumFractionDigits: 2 });
|
||||||
|
const yuan = (fen: number) => (fen / 100).toFixed(2);
|
||||||
|
|
||||||
|
export function SubscriptionPage() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="border-b border-gray-200 pb-4">
|
||||||
|
<h3 className="text-base font-semibold text-gray-800">支付 · 订阅</h3>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
订阅套餐定价与发放节奏 · 全平台订阅状态。手动购买制,到期即失效,不自动续费
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<PlansBlock />
|
||||||
|
<SubsBlock />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlansBlock() {
|
||||||
|
const [rows, setRows] = useState<SubPlan[]>([]);
|
||||||
|
const [name, setName] = useState("");
|
||||||
|
const [price, setPrice] = useState("");
|
||||||
|
const [duration, setDuration] = useState("30");
|
||||||
|
const [refill, setRefill] = useState("");
|
||||||
|
const [interval, setIntervalDays] = useState("7");
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
const load = () => adminSubPlans().then(setRows).catch((e) => setErr((e as Error).message));
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const add = async () => {
|
||||||
|
const p = Number(price), d = Number(duration), r = Number(refill), i = Number(interval);
|
||||||
|
if (!name.trim() || !p || !d || !r || !i || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
await saveSubPlan({
|
||||||
|
name: name.trim(), price_fen: Math.round(p * 100), duration_days: d,
|
||||||
|
refill_credits: r, refill_interval_days: i, active: true, sort: 0,
|
||||||
|
});
|
||||||
|
setName(""); setPrice(""); setRefill("");
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggle = async (p: SubPlan) => {
|
||||||
|
try {
|
||||||
|
await saveSubPlan({
|
||||||
|
id: p.id, name: p.name, price_fen: p.price_fen, duration_days: p.duration_days,
|
||||||
|
refill_credits: p.refill_credits_micro / MICRO, refill_interval_days: p.refill_interval_days,
|
||||||
|
active: !p.active, sort: p.sort,
|
||||||
|
});
|
||||||
|
await load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 一个周期发几次:让人在配的时候就看见后果,而不是上线后才发现只发了一次。
|
||||||
|
const times = (p: { duration_days: number; refill_interval_days: number }) =>
|
||||||
|
p.refill_interval_days > 0 ? Math.floor(p.duration_days / p.refill_interval_days) : 0;
|
||||||
|
|
||||||
|
const previewTimes = times({ duration_days: Number(duration) || 0, refill_interval_days: Number(interval) || 0 });
|
||||||
|
const previewTotal = previewTimes * (Number(refill) || 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">订阅套餐</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">积分按周期发放且累加,不清零用户已有积分</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-end gap-2">
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
名称
|
||||||
|
<input value={name} onChange={(e) => setName(e.target.value)} placeholder="如:专业版月付"
|
||||||
|
className="mt-1 block w-36 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
售价(¥)
|
||||||
|
<input value={price} onChange={(e) => setPrice(e.target.value)} inputMode="decimal" placeholder="99"
|
||||||
|
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
时长(天)
|
||||||
|
<input value={duration} onChange={(e) => setDuration(e.target.value)} inputMode="numeric"
|
||||||
|
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
每次发放(积分)
|
||||||
|
<input value={refill} onChange={(e) => setRefill(e.target.value)} inputMode="numeric" placeholder="1000"
|
||||||
|
className="mt-1 block w-28 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<label className="text-xs text-gray-500">
|
||||||
|
每几天发
|
||||||
|
<input value={interval} onChange={(e) => setIntervalDays(e.target.value)} inputMode="numeric"
|
||||||
|
className="mt-1 block w-20 rounded-lg border border-gray-200 px-2.5 py-1.5 text-sm text-gray-800 focus:border-violet-400 focus:outline-none" />
|
||||||
|
</label>
|
||||||
|
<button onClick={() => void add()} disabled={busy}
|
||||||
|
className="rounded-lg bg-violet-600 px-3.5 py-1.5 text-sm text-white hover:bg-violet-700 disabled:opacity-40">
|
||||||
|
{busy ? "…" : "新增"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 配置后果预览:省得上线后才发现节奏配错 */}
|
||||||
|
{previewTimes > 0 && (
|
||||||
|
<p className="mt-2 text-[11px] text-gray-500">
|
||||||
|
一个周期发 <b className="text-violet-600">{previewTimes}</b> 次,合计{" "}
|
||||||
|
<b className="text-violet-600">{previewTotal.toLocaleString("zh-CN")}</b> 积分
|
||||||
|
{Number(duration) % Number(interval) !== 0 && (
|
||||||
|
<span className="text-amber-600">({Number(duration)} 不能被 {Number(interval)} 整除,最后一次发放后到期前会有空档)</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{err && <p className="mt-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">名称</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">售价</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">时长</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">发放节奏</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">周期总积分</th>
|
||||||
|
<th className="py-2 text-right font-medium">状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((p) => (
|
||||||
|
<tr key={p.id} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 pr-3 text-gray-800">{p.name}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">¥{yuan(p.price_fen)}</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-500">{p.duration_days} 天</td>
|
||||||
|
<td className="py-2 pr-3 text-right text-[11px] tabular-nums text-gray-600">
|
||||||
|
每 {p.refill_interval_days} 天 · {credits(p.refill_credits_micro)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-800">
|
||||||
|
{credits(p.refill_credits_micro * times(p))}
|
||||||
|
<span className="ml-1 text-[10px] text-gray-400">({times(p)} 次)</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 text-right">
|
||||||
|
<button onClick={() => void toggle(p)}
|
||||||
|
className={`rounded border px-2 py-0.5 text-[11px] ${p.active ? "border-emerald-200 text-emerald-600 hover:bg-emerald-50" : "border-gray-200 text-gray-400 hover:bg-gray-50"}`}>
|
||||||
|
{p.active ? "在售 · 点击下架" : "已下架 · 点击上架"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="py-6 text-center text-xs text-gray-400">还没有订阅套餐</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SubsBlock() {
|
||||||
|
const [rows, setRows] = useState<SubRow[]>([]);
|
||||||
|
const [err, setErr] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
adminSubscriptions().then(setRows).catch((e) => setErr((e as Error).message));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const daysLeft = (iso: string) => Math.ceil((new Date(iso).getTime() - now) / 86400000);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow-sm">
|
||||||
|
<div className="mb-3 flex items-center justify-between">
|
||||||
|
<h4 className="text-sm font-semibold text-gray-700">全平台订阅</h4>
|
||||||
|
<span className="text-[11px] text-gray-400">到期即失效,不自动续费;剩余天数少的排在前面</span>
|
||||||
|
</div>
|
||||||
|
{err && <p className="mb-2 text-xs text-rose-500">{err}</p>}
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-100 text-left text-[11px] uppercase tracking-wide text-gray-400">
|
||||||
|
<th className="py-2 pr-3 font-medium">租户</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">套餐</th>
|
||||||
|
<th className="py-2 pr-3 font-medium">状态</th>
|
||||||
|
<th className="py-2 pr-3 text-right font-medium">已发放</th>
|
||||||
|
<th className="py-2 text-right font-medium">到期</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((s) => {
|
||||||
|
const left = daysLeft(s.expires_at);
|
||||||
|
return (
|
||||||
|
<tr key={s.id} className="border-b border-gray-50 last:border-0">
|
||||||
|
<td className="py-2 pr-3 text-gray-800">{s.tenant_name || s.tenant_id}</td>
|
||||||
|
<td className="py-2 pr-3 text-gray-600">{s.plan_name || "—"}</td>
|
||||||
|
<td className="py-2 pr-3">
|
||||||
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${s.status === "active" ? "bg-emerald-50 text-emerald-600" : "bg-gray-100 text-gray-500"}`}>
|
||||||
|
{s.status === "active" ? "生效中" : "已过期"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 pr-3 text-right tabular-nums text-gray-600">{s.refill_seq} 次</td>
|
||||||
|
<td className="py-2 text-right text-[11px] tabular-nums">
|
||||||
|
<span className="text-gray-500">{new Date(s.expires_at).toLocaleDateString("zh-CN")}</span>
|
||||||
|
{s.status === "active" && (
|
||||||
|
// 快到期的要显眼:到期即失效,没有自动续费兜底
|
||||||
|
<span className={`ml-1.5 ${left <= 3 ? "font-medium text-rose-600" : left <= 7 ? "text-amber-600" : "text-gray-400"}`}>
|
||||||
|
剩 {left} 天
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{rows.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={5} className="py-6 text-center text-xs text-gray-400">还没有人订阅</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { SubscriptionPage } from "./pages/SubscriptionPage";
|
||||||
import { lazy, type ReactNode } from "react";
|
import { lazy, type ReactNode } from "react";
|
||||||
|
|
||||||
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
// 路由注册表 —— 控制台的单一事实源:导航 + 内容都从这里派生。
|
||||||
@@ -98,6 +99,14 @@ export const routes: RouteDef[] = [
|
|||||||
ready: true,
|
ready: true,
|
||||||
element: <PaymentConfigPage />,
|
element: <PaymentConfigPage />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "payment/subscription",
|
||||||
|
label: "订阅",
|
||||||
|
group: "运维",
|
||||||
|
parent: "支付",
|
||||||
|
ready: true,
|
||||||
|
element: <SubscriptionPage />,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "payment/orders",
|
path: "payment/orders",
|
||||||
label: "订单与对账",
|
label: "订单与对账",
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ func applyUsageCredit(tx *gorm.DB, tenantID, taskID string, creditsMicro int64)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", creditsMicro)).Error
|
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", creditsMicro)).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// upsertRollup 在事务内累加 租户/天 聚合。
|
// upsertRollup 在事务内累加 租户/天 聚合。
|
||||||
@@ -165,6 +165,6 @@ func (p *Postgres) GrantCredits(ctx context.Context, tenantID, kind string, cred
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
return tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", creditsMicro)).Error
|
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", creditsMicro)).Error
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,7 +89,9 @@ type Tenant struct {
|
|||||||
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
Slug string `gorm:"size:64;uniqueIndex"` // 唯一短标识(默认租户用 default-<uid>)
|
||||||
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
Plan string `gorm:"size:32;default:free"` // free / pro / enterprise
|
||||||
Status string `gorm:"size:16;default:active"` // active / suspended
|
Status string `gorm:"size:16;default:active"` // active / suspended
|
||||||
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
|
// 只给 default,**不加 not null**:存量库里这一列有历史 NULL 行,AutoMigrate 若尝试
|
||||||
|
// SET NOT NULL 会直接失败(且它在回填之前跑)。空值由入账处的 coalesce + 启动回填兜住。
|
||||||
|
CreditBalanceMicro int64 `gorm:"column:credit_balance_micro;default:0"` // 物化积分余额 ×10⁻⁶(= credit_ledger 之和;用量扣、充值增)
|
||||||
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
|
SharedBilling bool `gorm:"column:shared_billing"` // 共享计费:开=成员消耗计本租户池;关=成员计各自个人池(owner 恒计本租户)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -188,7 +188,7 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (*
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
if err := tx.Model(&Tenant{}).Where("id = ?", tenantID).
|
||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", rc.CreditsMicro)).Error; err != nil {
|
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", rc.CreditsMicro)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
order = o
|
order = o
|
||||||
@@ -253,7 +253,7 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil {
|
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) + ?", o.CreditsMicro)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,7 +315,7 @@ func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, mem
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||||
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
|
UpdateColumn("credit_balance_micro", gorm.Expr("coalesce(credit_balance_micro, 0) - ?", o.CreditsMicro)).Error; err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
changed = true
|
changed = true
|
||||||
|
|||||||
@@ -80,6 +80,16 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
|
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
|
||||||
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
|
log.Printf("[store] 账本 adjust/ref 唯一索引创建失败(重复退款兜底闸缺位): %v", err)
|
||||||
}
|
}
|
||||||
|
// 回填历史 NULL 余额。credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
|
||||||
|
// 而入账用的是 `余额 + N` —— SQL 里 NULL + N 仍是 NULL,于是这些租户**充值永远不到账**
|
||||||
|
// (分录照写、余额不动),且不报错。代码侧已改 coalesce 自愈,这里把存量一次修平,
|
||||||
|
// 让「余额 = SUM(ledger)」这条对账不变量重新成立。
|
||||||
|
if err := db.Exec(`UPDATE sundynix_tenant SET credit_balance_micro = COALESCE(
|
||||||
|
(SELECT SUM(credits_micro) FROM sundynix_credit_ledger l WHERE l.tenant_id = sundynix_tenant.id), 0)
|
||||||
|
WHERE credit_balance_micro IS NULL`).Error; err != nil {
|
||||||
|
log.Printf("[store] 历史 NULL 余额回填失败: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
|
registerTenantScope(db) // 多租户:受租户模型的查询/创建自动按上下文注入 tenant_id(统一强制隔离)
|
||||||
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
|
log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)")
|
||||||
return &Postgres{db: db}
|
return &Postgres{db: db}
|
||||||
|
|||||||
@@ -246,3 +246,25 @@ func TestSubscription_ActivatedByOrderPayment(t *testing.T) {
|
|||||||
t.Fatalf("重复回调不该重复发放,得 %d", got)
|
t.Fatalf("重复回调不该重复发放,得 %d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 余额列为 NULL 时入账必须仍然生效。
|
||||||
|
// 真实事故:credit_balance_micro 是后加的列,早于它创建的租户行值为 NULL,
|
||||||
|
// 而入账语句是「余额 + N」——SQL 里 NULL + N = NULL,于是这些租户**充值永远不到账**,
|
||||||
|
// 分录照写、余额不动、且不报错。本地库 42 个租户里有 11 个处于此状态。
|
||||||
|
func TestGrantCredits_SurvivesNullBalance(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t-null")
|
||||||
|
// 造出"历史租户":把余额置回 NULL
|
||||||
|
if err := p.db.WithContext(WithoutTenant(ctx)).Exec(
|
||||||
|
"UPDATE sundynix_tenant SET credit_balance_micro = NULL WHERE id = ?", "t-null").Error; err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := p.GrantCredits(ctx, "t-null", LedgerGrant, 50_000_000, "ref-null", "充值"); err != nil {
|
||||||
|
t.Fatalf("入账失败: %v", err)
|
||||||
|
}
|
||||||
|
if got := balance(t, p, "t-null"); got != 50_000_000 {
|
||||||
|
t.Fatalf("NULL 余额的租户充值后应为 50e6,得 %d —— coalesce 丢了?", got)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t-null")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user