feat(billing): 人工退款入口 —— paid 单冲销积分+置 refunded(幂等)
PAYMENT_DESIGN §5 承诺却只留了 OrderRefunded 常量、无入口。补齐全链路: - store.RefundOrder:订单 CAS(paid→refunded) 为主闸(重复退 changed=false 幂等), 同事务记 adjust 负分录(ref=订单号)+ 回退物化余额。照抄 MarkOrderPaid 双闸范式; 新增 idx_ledger_refund_ref 部分唯一索引(kind='adjust' AND ref<>'')做账本级兜底, 与 grant 索引对称、不与 admin 手工校正(ref 空)冲突。 - 积分若已消费,回退后余额可为负(人工退款预期,账本仍自洽,后续消费被硬拦截)。 - handler AdminRefundOrder + POST /admin/orders/:id/refund(admin 组已挂 Audit 留痕); 真渠道钱款原路退回需 admin 另在商户后台操作,本地仅冲销积分与订单态(不接自动退款 API)。 - admin 订单流加「退款」按钮(仅 paid 单可见,二次确认+填原因)。 - 测试:RefundOrder 冲销+幂等、只退 paid 两个不变量测试(sqlite 真 DB,余额=账本之和)。 gateway build/vet/test 全绿,admin tsc 干净。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -301,6 +301,19 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo
|
|||||||
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
return { diffs: d.diffs ?? [], ok: !!d.ok };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 人工退款:仅对已入账(paid)单——置 refunded + 记 adjust 负分录 + 回退余额(幂等)。
|
||||||
|
// status="noop" 表示该单本就无需退(已退/未支付)。真渠道钱的原路退回需 admin 另在商户后台操作。
|
||||||
|
export async function adminRefundOrder(id: string, memo: string): Promise<{ status: string; detail?: string }> {
|
||||||
|
const res = guard(await fetch(`${ADMIN}/orders/${id}/refund`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...authHeaders(), "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ memo }),
|
||||||
|
}));
|
||||||
|
const d = (await res.json().catch(() => ({}))) as { status?: string; detail?: string; error?: string };
|
||||||
|
if (!res.ok) throw new Error(d.error ?? `refund failed: ${res.status}`);
|
||||||
|
return { status: d.status ?? "", detail: d.detail };
|
||||||
|
}
|
||||||
|
|
||||||
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
// ---- 自动评测观测(真数据,来自 sundynix_eval)----
|
||||||
export interface EvalDay {
|
export interface EvalDay {
|
||||||
day: string; // YYYYMMDD
|
day: string; // YYYYMMDD
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { adminOrders, adminReconcile, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
import { adminOrders, adminReconcile, adminRefundOrder, type PayOrder, type OrderStats, type ReconcileDiff } from "../api";
|
||||||
|
|
||||||
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
// 充值订单流 + 日终对账(P5.3 观测)。全平台充值单的落地视角:
|
||||||
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
// - 状态计数卡片(pending/paid/expired + 累计到账额)
|
||||||
@@ -27,6 +27,7 @@ export function OrderStream() {
|
|||||||
// 对账结果:null=未跑;[]=零差异;有元素=有差异
|
// 对账结果:null=未跑;[]=零差异;有元素=有差异
|
||||||
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
const [diffs, setDiffs] = useState<ReconcileDiff[] | null>(null);
|
||||||
const [checking, setChecking] = useState(false);
|
const [checking, setChecking] = useState(false);
|
||||||
|
const [refunding, setRefunding] = useState(""); // 正在退款的订单 id
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
adminOrders(filter)
|
adminOrders(filter)
|
||||||
@@ -51,6 +52,27 @@ export function OrderStream() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const refund = async (o: PayOrder) => {
|
||||||
|
// 退款不可逆(冲销积分、订单置 refunded),且真渠道钱需另在商户后台原路退——二次确认 + 留原因。
|
||||||
|
const memo = window.prompt(
|
||||||
|
`确认退款?将冲销 ${credits(o.credits_micro)} 积分并把订单置为已退款(余额可能因积分已消费而变负)。\n` +
|
||||||
|
`注意:微信真单的钱款原路退回需另在商户后台操作,此处仅冲销本地积分与订单态。\n\n请填写退款原因:`,
|
||||||
|
"",
|
||||||
|
);
|
||||||
|
if (memo === null) return; // 取消
|
||||||
|
setRefunding(o.id);
|
||||||
|
setErr("");
|
||||||
|
try {
|
||||||
|
const r = await adminRefundOrder(o.id, memo);
|
||||||
|
if (r.status === "noop") setErr(r.detail ?? "该订单无需退款");
|
||||||
|
load();
|
||||||
|
} catch (e) {
|
||||||
|
setErr((e as Error).message);
|
||||||
|
} finally {
|
||||||
|
setRefunding("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex items-center gap-2 pt-1">
|
<div className="flex items-center gap-2 pt-1">
|
||||||
@@ -117,7 +139,8 @@ export function OrderStream() {
|
|||||||
<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 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 font-medium">状态</th>
|
<th className="py-2 pr-3 font-medium">状态</th>
|
||||||
|
<th className="py-2 text-right font-medium">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -128,16 +151,26 @@ export function OrderStream() {
|
|||||||
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
|
<td className="py-1.5 pr-3 text-xs text-gray-500">{CHANNEL_LABEL[o.channel] ?? o.channel}</td>
|
||||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
|
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-800">{credits(o.credits_micro)}</td>
|
||||||
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
|
<td className="py-1.5 pr-3 text-right tabular-nums text-gray-500">{o.amount_fen > 0 ? yuan(o.amount_fen) : "—"}</td>
|
||||||
<td className="py-1.5">
|
<td className="py-1.5 pr-3">
|
||||||
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
|
<span className={`rounded px-1.5 py-0.5 text-[10px] ${STATUS_BADGE[o.status] ?? "bg-gray-100 text-gray-500"}`}>
|
||||||
{STATUS_LABEL[o.status] ?? o.status}
|
{STATUS_LABEL[o.status] ?? o.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
|
<td className="py-1.5 text-right">
|
||||||
|
{o.status === "paid" ? (
|
||||||
|
<button onClick={() => void refund(o)} disabled={refunding === o.id}
|
||||||
|
className="rounded border border-rose-200 px-2 py-0.5 text-[11px] text-rose-600 hover:bg-rose-50 disabled:opacity-40">
|
||||||
|
{refunding === o.id ? "退款中…" : "退款"}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<span className="text-[11px] text-gray-300">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{orders.length === 0 && (
|
{orders.length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
<td colSpan={7} className="py-6 text-center text-xs text-gray-400">暂无充值订单</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
|
|||||||
@@ -298,3 +298,26 @@ func (h *Handler) AdminReconcile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
|
c.JSON(http.StatusOK, gin.H{"diffs": rows, "ok": len(rows) == 0})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminRefundOrder: POST /api/v1/admin/orders/:id/refund —— 人工退款(PAYMENT_DESIGN §5)。
|
||||||
|
// 只退 paid 单:订单置 refunded + 记 adjust 负分录 + 回退余额(幂等,可能扣成负余额)。
|
||||||
|
// 真渠道(微信)退款仅冲销本地积分与订单态,钱的原路退回由 admin 在微信商户后台线下操作
|
||||||
|
// —— 本期不接自动退款 API(PAYMENT_DESIGN 明确不做),故 memo 里留操作痕迹。
|
||||||
|
func (h *Handler) AdminRefundOrder(c *gin.Context) {
|
||||||
|
id := c.Param("id")
|
||||||
|
var b struct {
|
||||||
|
Memo string `json:"memo"`
|
||||||
|
}
|
||||||
|
_ = c.ShouldBindJSON(&b) // memo 可选
|
||||||
|
changed, err := h.db.RefundOrder(c.Request.Context(), id, userID(c), b.Memo)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
// 幂等:本就无需退(已退 / 未支付 / 不存在)。据现状返回可读提示,不当错误。
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "noop", "detail": "订单非已支付状态或已退款,未做冲销"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"status": "refunded", "order_id": id})
|
||||||
|
}
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob.
|
|||||||
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
admin.PUT("/payment/wechat", h.AdminSaveWechatPay)
|
||||||
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数
|
||||||
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant
|
||||||
|
admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计)
|
||||||
// 多租户成员管理(平台运维口径)
|
// 多租户成员管理(平台运维口径)
|
||||||
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额)
|
||||||
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner)
|
||||||
|
|||||||
@@ -249,6 +249,55 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string
|
|||||||
return changed, err
|
return changed, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundOrder 人工退款(PAYMENT_DESIGN §5:admin 发起 → 订单置 refunded + 记 adjust 负分录 + 回退余额)。
|
||||||
|
// 只退 paid 单。幂等照抄 MarkOrderPaid 范式:**订单 CAS(paid→refunded) 为主闸**——RowsAffected==0
|
||||||
|
// 表示已退过(或非 paid),直接幂等成功,不重复冲销。credit_ledger 的 (kind='adjust', ref=订单号)
|
||||||
|
// 部分唯一索引兜底(与 grant 的双闸对称)。
|
||||||
|
// 注:若积分已被消费,回退后物化余额可能为负——这是人工退款的预期(钱退了、积分早花了),
|
||||||
|
// 账本仍自洽(余额 = SUM(ledger)),后续消费被积分硬拦截挡住直到再充值。
|
||||||
|
// 返回 changed=false 表示这单本就无需退(已退/未支付/不存在),幂等。
|
||||||
|
func (p *Postgres) RefundOrder(ctx context.Context, orderID, operatorUserID, memo string) (bool, error) {
|
||||||
|
if p.db == nil {
|
||||||
|
return false, errStoreDisabled
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(orderID) == "" {
|
||||||
|
return false, errors.New("订单号必填")
|
||||||
|
}
|
||||||
|
if strings.TrimSpace(memo) == "" {
|
||||||
|
memo = "人工退款"
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error {
|
||||||
|
res := tx.Model(&PaymentOrder{}).
|
||||||
|
Where("id = ? AND status = ?", orderID, OrderPaid).
|
||||||
|
Update("status", OrderRefunded)
|
||||||
|
if res.Error != nil {
|
||||||
|
return res.Error
|
||||||
|
}
|
||||||
|
if res.RowsAffected == 0 {
|
||||||
|
return nil // 已退 / 非 paid / 不存在 —— 幂等,不冲销
|
||||||
|
}
|
||||||
|
var o PaymentOrder
|
||||||
|
if err := tx.First(&o, "id = ?", orderID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 负分录:kind=adjust、ref=订单号(不撞 grant 的 kind=grant 同 ref;adjust 部分唯一索引兜住重复退)。
|
||||||
|
if err := tx.Create(&CreditLedger{
|
||||||
|
TenantID: o.TenantID, Kind: LedgerAdjust, CreditsMicro: -o.CreditsMicro, Ref: o.ID,
|
||||||
|
Memo: "退款 " + o.Channel + ":" + memo + "(操作人 " + operatorUserID + ")",
|
||||||
|
}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID).
|
||||||
|
UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro - ?", o.CreditsMicro)).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
changed = true
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
|
||||||
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
|
// PendingWechatOrders 捞出所有 pending 的微信订单(掉单补偿定时器扫描用)。
|
||||||
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
|
// 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。
|
||||||
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
|
func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) {
|
||||||
|
|||||||
@@ -158,6 +158,82 @@ func TestSaveUsageEvent_IdempotentByTask(t *testing.T) {
|
|||||||
assertBalanceInvariant(t, p, "t1")
|
assertBalanceInvariant(t, p, "t1")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RefundOrder:paid 单退款——置 refunded + adjust 负分录 + 回退余额,且 CAS 幂等(重复退不重复冲销)。
|
||||||
|
func TestRefundOrder_ReversesAndIdempotent(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
|
||||||
|
// 先充一笔微信单入账。
|
||||||
|
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 990, CreditsMicro: 100_000_000, Channel: ChannelWechat, Status: OrderPending}
|
||||||
|
if err := p.CreateOrder(ctx, o); err != nil {
|
||||||
|
t.Fatalf("建单失败: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := p.MarkOrderPaid(ctx, o.ID, "wx-txn"); err != nil {
|
||||||
|
t.Fatalf("入账失败: %v", err)
|
||||||
|
}
|
||||||
|
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 100_000_000 {
|
||||||
|
t.Fatalf("入账后余额应 100e6,得 %d", bal)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 退款 → changed=true,余额回退到 0,订单置 refunded。
|
||||||
|
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "客户申请")
|
||||||
|
if err != nil || !changed {
|
||||||
|
t.Fatalf("首次退款应 changed=true, err=%v", err)
|
||||||
|
}
|
||||||
|
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
|
||||||
|
t.Fatalf("退款后余额应回 0,得 %d", bal)
|
||||||
|
}
|
||||||
|
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
|
||||||
|
if got.Status != OrderRefunded {
|
||||||
|
t.Fatalf("订单应 refunded,得 %s", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重复退款 → changed=false(CAS 拦),余额纹丝不动,不产生第二条负分录。
|
||||||
|
changed2, err := p.RefundOrder(ctx, o.ID, "admin1", "重复点")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("重复退款不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if changed2 {
|
||||||
|
t.Fatal("重复退款应 changed=false(幂等)")
|
||||||
|
}
|
||||||
|
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
|
||||||
|
t.Fatalf("重复退款后余额不应变,得 %d", bal)
|
||||||
|
}
|
||||||
|
assertBalanceInvariant(t, p, "t1")
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefundOrder:只退 paid 单——pending/未存在的单退款是幂等 no-op(changed=false),不误伤。
|
||||||
|
func TestRefundOrder_OnlyPaid(t *testing.T) {
|
||||||
|
p := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
seedTenant(t, p, "t1")
|
||||||
|
|
||||||
|
// pending 单不可退。
|
||||||
|
o := &PaymentOrder{TenantID: "t1", UserID: "u1", AmountFen: 100, CreditsMicro: 1_000_000, Channel: ChannelWechat, Status: OrderPending}
|
||||||
|
p.CreateOrder(ctx, o)
|
||||||
|
changed, err := p.RefundOrder(ctx, o.ID, "admin1", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("退 pending 单不应报错: %v", err)
|
||||||
|
}
|
||||||
|
if changed {
|
||||||
|
t.Fatal("pending 单不该被退(changed=false)")
|
||||||
|
}
|
||||||
|
// 余额未动、订单仍 pending。
|
||||||
|
if bal := p.TenantBalance(WithoutTenant(ctx), "t1"); bal != 0 {
|
||||||
|
t.Fatalf("退 pending 后余额应仍 0,得 %d", bal)
|
||||||
|
}
|
||||||
|
got, _ := p.GetOrder(WithoutTenant(ctx), o.ID)
|
||||||
|
if got.Status != OrderPending {
|
||||||
|
t.Fatalf("pending 单状态不该变,得 %s", got.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不存在的单 → 幂等 no-op。
|
||||||
|
if changed, err := p.RefundOrder(ctx, "no-such-order", "admin1", ""); err != nil || changed {
|
||||||
|
t.Fatalf("退不存在的单应 no-op(changed=false, err=nil),得 changed=%v err=%v", changed, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ReconcileOrders:paid 订单缺对应 grant 分录 → 抓出 order_without_ledger(钱到了积分没给,最严重)。
|
// ReconcileOrders:paid 订单缺对应 grant 分录 → 抓出 order_without_ledger(钱到了积分没给,最严重)。
|
||||||
func TestReconcileOrders_DetectsMissingLedger(t *testing.T) {
|
func TestReconcileOrders_DetectsMissingLedger(t *testing.T) {
|
||||||
p := newTestStore(t)
|
p := newTestStore(t)
|
||||||
|
|||||||
@@ -75,6 +75,11 @@ func OpenPostgres(dsn string) *Postgres {
|
|||||||
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
|
if err := db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
|
||||||
log.Printf("[store] 账本 grant/ref 唯一索引创建失败(重复入账兜底闸缺位): %v", err)
|
log.Printf("[store] 账本 grant/ref 唯一索引创建失败(重复入账兜底闸缺位): %v", err)
|
||||||
}
|
}
|
||||||
|
// 退款幂等兜底闸:adjust 分录带 ref(=订单号) 唯一——防重复退款冲销。
|
||||||
|
// 部分索引:admin 手工校正(GrantCredits 负数)ref 为空,不受约束;与 grant 双闸对称。
|
||||||
|
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)
|
||||||
|
}
|
||||||
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}
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ func newTestStore(t *testing.T) *Postgres {
|
|||||||
if err := db.Exec(`CREATE UNIQUE INDEX idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
|
if err := db.Exec(`CREATE UNIQUE INDEX idx_ledger_grant_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'grant' AND ref <> ''`).Error; err != nil {
|
||||||
t.Fatalf("建幂等索引失败: %v", err)
|
t.Fatalf("建幂等索引失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if err := db.Exec(`CREATE UNIQUE INDEX idx_ledger_refund_ref ON sundynix_credit_ledger (kind, ref) WHERE kind = 'adjust' AND ref <> ''`).Error; err != nil {
|
||||||
|
t.Fatalf("建退款幂等索引失败: %v", err)
|
||||||
|
}
|
||||||
registerTenantScope(db)
|
registerTenantScope(db)
|
||||||
return &Postgres{db: db}
|
return &Postgres{db: db}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user