diff --git a/sundynix-admin/src/api.ts b/sundynix-admin/src/api.ts index 022dece..10c32af 100644 --- a/sundynix-admin/src/api.ts +++ b/sundynix-admin/src/api.ts @@ -301,6 +301,19 @@ export async function adminReconcile(): Promise<{ diffs: ReconcileDiff[]; ok: bo 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)---- export interface EvalDay { day: string; // YYYYMMDD diff --git a/sundynix-admin/src/components/OrderStream.tsx b/sundynix-admin/src/components/OrderStream.tsx index 3834511..7267421 100644 --- a/sundynix-admin/src/components/OrderStream.tsx +++ b/sundynix-admin/src/components/OrderStream.tsx @@ -1,5 +1,5 @@ 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 观测)。全平台充值单的落地视角: // - 状态计数卡片(pending/paid/expired + 累计到账额) @@ -27,6 +27,7 @@ export function OrderStream() { // 对账结果:null=未跑;[]=零差异;有元素=有差异 const [diffs, setDiffs] = useState(null); const [checking, setChecking] = useState(false); + const [refunding, setRefunding] = useState(""); // 正在退款的订单 id const load = useCallback(() => { 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 (
@@ -117,7 +139,8 @@ export function OrderStream() { 渠道 积分 金额 - 状态 + 状态 + 操作 @@ -128,16 +151,26 @@ export function OrderStream() { {CHANNEL_LABEL[o.channel] ?? o.channel} {credits(o.credits_micro)} {o.amount_fen > 0 ? yuan(o.amount_fen) : "—"} - + {STATUS_LABEL[o.status] ?? o.status} + + {o.status === "paid" ? ( + + ) : ( + + )} + ))} {orders.length === 0 && ( - 暂无充值订单 + 暂无充值订单 )} diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 2fb6549..00ffcb9 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -298,3 +298,26 @@ func (h *Handler) AdminReconcile(c *gin.Context) { } 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}) +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 622acaa..6aa34ce 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -144,6 +144,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. admin.PUT("/payment/wechat", h.AdminSaveWechatPay) admin.GET("/orders", h.AdminOrders) // 全平台充值订单流 + 状态计数 admin.GET("/orders/reconcile", h.AdminReconcile) // 日终对账:paid 单 ↔ 账本 grant + admin.POST("/orders/:id/refund", h.AdminRefundOrder) // 人工退款:置 refunded + adjust 负分录 + 回退余额(审计) // 多租户成员管理(平台运维口径) admin.GET("/tenants", h.AdminTenants) // 租户目录(成员数+余额) admin.POST("/tenants", h.AdminCreateTenant) // 新建租户(可选指定 owner) diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go index 589001c..f373f93 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -249,6 +249,55 @@ func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string 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 的微信订单(掉单补偿定时器扫描用)。 // 只取微信单:兑换码单核销即 paid,永不 pending,不需要查单。按创建时间升序,先补老单。 func (p *Postgres) PendingWechatOrders(ctx context.Context, limit int) ([]PaymentOrder, error) { diff --git a/sundynix-gateway/internal/store/payment_invariant_test.go b/sundynix-gateway/internal/store/payment_invariant_test.go index 34272ff..71441c5 100644 --- a/sundynix-gateway/internal/store/payment_invariant_test.go +++ b/sundynix-gateway/internal/store/payment_invariant_test.go @@ -158,6 +158,82 @@ func TestSaveUsageEvent_IdempotentByTask(t *testing.T) { 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(钱到了积分没给,最严重)。 func TestReconcileOrders_DetectsMissingLedger(t *testing.T) { p := newTestStore(t) diff --git a/sundynix-gateway/internal/store/pgsql.go b/sundynix-gateway/internal/store/pgsql.go index ba54723..61e2661 100644 --- a/sundynix-gateway/internal/store/pgsql.go +++ b/sundynix-gateway/internal/store/pgsql.go @@ -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 { 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(统一强制隔离) log.Println("[store] postgres connected & migrated (雪花 id + 软删 规约)") return &Postgres{db: db} diff --git a/sundynix-gateway/internal/store/testdb_test.go b/sundynix-gateway/internal/store/testdb_test.go index 79487ba..45fc5c8 100644 --- a/sundynix-gateway/internal/store/testdb_test.go +++ b/sundynix-gateway/internal/store/testdb_test.go @@ -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 { 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) return &Postgres{db: db} }