diff --git a/im/backend/internal/app/app.go b/im/backend/internal/app/app.go index f851731..883a5a7 100644 --- a/im/backend/internal/app/app.go +++ b/im/backend/internal/app/app.go @@ -367,6 +367,7 @@ func (a *App) adminRoutes() []rest.Route { {Method: http.MethodGet, Path: "/admin/v1/pet/listings", Handler: permit("users:view", a.adminPetListings)}, {Method: http.MethodPost, Path: "/admin/v1/pet/listings/:id/moderate", Handler: permit("users:manage", a.adminModeratePetListing)}, {Method: http.MethodPost, Path: "/admin/v1/pets/:id/moderate", Handler: permit("users:manage", a.adminModeratePet)}, + {Method: http.MethodGet, Path: "/admin/v1/pet/overview", Handler: permit("users:view", a.adminPetOverview)}, {Method: http.MethodGet, Path: "/admin/v1/pet/sitters", Handler: permit("users:view", a.adminSitters)}, {Method: http.MethodPost, Path: "/admin/v1/pet/sitters/:id/review", Handler: permit("users:manage", a.adminReviewSitter)}, {Method: http.MethodGet, Path: "/admin/v1/pet/feed-tasks", Handler: permit("users:view", a.adminFeedTasks)}, diff --git a/im/backend/internal/app/pet_feed_integration_test.go b/im/backend/internal/app/pet_feed_integration_test.go index 5ccb0f6..301cb78 100644 --- a/im/backend/internal/app/pet_feed_integration_test.go +++ b/im/backend/internal/app/pet_feed_integration_test.go @@ -427,3 +427,81 @@ func seedAssignedTask(t *testing.T, a *App, db *sql.DB, owner, sitter int64) int } return taskID } + +// The overview is what an operator opens first; if its numbers drift from the +// lists behind them, they stop trusting the console and go back to SQL. +func TestPetOverviewMySQL(t *testing.T) { + db := isolatedIMDatabase(t) + a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}} + a.config.ConfigEncryptionKey = strings.Repeat("k", 32) + owner, sitter := int64(1), int64(2) + + read := func() map[string]any { + t.Helper() + var view map[string]any + if err := json.Unmarshal(imTestCall(t, a.adminPetOverview, 1, "GET", "/admin/v1/pet/overview", "", 200), &view); err != nil { + t.Fatal(err) + } + return view + } + number := func(view map[string]any, group, key string) int64 { + t.Helper() + section, ok := view[group].(map[string]any) + if !ok { + t.Fatalf("%s 不是一组数字: %+v", group, view[group]) + } + return int64(section[key].(float64)) + } + + empty := read() + if number(empty, "pending", "disputes") != 0 || number(empty, "money", "escrowedCent") != 0 { + t.Fatalf("空库时待办与代管金额应当都是 0: %+v", empty) + } + + taskID := seedAssignedTask(t, a, db, owner, sitter) + // seedAssignedTask 停在"待确认":钱已经收了,还没结算给任何人。 + held := read() + if number(held, "money", "escrowedCent") != 9000 { + t.Fatalf("代管金额 = %d,应当是 3 次 30 元", number(held, "money", "escrowedCent")) + } + if number(held, "tasks", "COMPLETED") != 1 { + t.Fatalf("待确认任务数不对: %+v", held["tasks"]) + } + + raise := httptest.NewRecorder() + a.raiseFeedDispute(raise, imTestRequest(owner, "POST", fmt.Sprintf("/api/v1/pet/feed-tasks/%d/dispute", taskID), + `{"reason":"照片对不上,猫粮碗一直是满的,怀疑没有真的上门"}`)) + if raise.Code != 200 { + t.Fatalf("HTTP %d: %s", raise.Code, raise.Body.String()) + } + disputed := read() + if number(disputed, "pending", "disputes") != 1 { + t.Fatalf("待处理申诉应当是 1: %+v", disputed["pending"]) + } + // 申诉中的任务仍算代管:钱既没退也没结。 + if number(disputed, "money", "escrowedCent") != 9000 { + t.Fatalf("申诉期间代管金额不该消失: %d", number(disputed, "money", "escrowedCent")) + } + + var disputeID int64 + if err := db.QueryRow(`SELECT id FROM pet_feed_disputes WHERE task_id=?`, taskID).Scan(&disputeID); err != nil { + t.Fatal(err) + } + handle := httptest.NewRecorder() + a.adminHandleFeedDispute(handle, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/pet/disputes/%d/handle", disputeID), + `{"verdict":"payout","note":"照片与定位都对得上,判全额支付"}`)) + if handle.Code != 200 { + t.Fatalf("HTTP %d: %s", handle.Code, handle.Body.String()) + } + settled := read() + if number(settled, "pending", "disputes") != 0 || number(settled, "money", "escrowedCent") != 0 { + t.Fatalf("裁定之后待办与代管都应当归零: %+v", settled) + } + if number(settled, "money", "walletBalanceCent") != number(settled, "money", "settledCent") { + t.Fatalf("已结算金额与账户余额应当一致: %+v", settled["money"]) + } + // 账实一致是继续打款的前提,概览要能自己回答这件事。 + if settled["ledgerMismatches"].(float64) != 0 { + t.Fatalf("对账出现差异: %+v", settled) + } +} diff --git a/im/backend/internal/app/pet_overview.go b/im/backend/internal/app/pet_overview.go new file mode 100644 index 0000000..50dc371 --- /dev/null +++ b/im/backend/internal/app/pet_overview.go @@ -0,0 +1,80 @@ +package app + +import ( + "context" + "net/http" +) + +// The pet module's landing page. It answers the two questions an operator opens +// the console for — what is waiting for me, and is the money still adding up — +// so nobody has to click through four lists to find out that nothing is due. + +func (a *App) countOne(ctx context.Context, query string, args ...any) int64 { + var value int64 + _ = a.db.QueryRowContext(ctx, query, args...).Scan(&value) + return value +} + +func (a *App) adminPetOverview(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + // 待办:这几项有数字就意味着有人在等回复。 + pending := map[string]int64{ + "pets": a.countOne(ctx, `SELECT COUNT(*) FROM pets WHERE moderation_status=0 AND deleted_at IS NULL`), + "adoptions": a.countOne(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE moderation_status=0 AND deleted_at IS NULL`), + "matings": a.countOne(ctx, `SELECT COUNT(*) FROM pet_mating_listings WHERE moderation_status=0 AND deleted_at IS NULL`), + "sitters": a.countOne(ctx, `SELECT COUNT(*) FROM pet_sitters WHERE status=0`), + "disputes": a.countOne(ctx, `SELECT COUNT(*) FROM pet_feed_disputes WHERE status=0`), + "withdrawals": a.countOne(ctx, `SELECT COUNT(*) FROM withdrawals WHERE status IN ('PENDING','APPROVED')`), + } + + tasks := map[string]int64{} + rows, err := a.db.QueryContext(ctx, `SELECT status,COUNT(*) FROM pet_feed_tasks GROUP BY status`) + if err == nil { + defer rows.Close() + for rows.Next() { + var status string + var count int64 + if rows.Scan(&status, &count) == nil { + tasks[status] = count + } + } + } + + // 对账:余额与流水对不上就不该继续打款,所以这条和待办放在同一屏。 + mismatches := a.countOne(ctx, `SELECT COUNT(*) FROM ( + SELECT a.user_id FROM wallet_accounts a LEFT JOIN wallet_transactions t ON t.user_id=a.user_id + GROUP BY a.user_id,a.available_cent HAVING a.available_cent<>COALESCE(SUM(t.amount_cent*t.direction),0)) AS drift`) + + reply(w, map[string]any{ + "pending": pending, + "tasks": tasks, + "money": map[string]int64{ + // 代管中:这笔钱已经从主人那里收了,但还没结算给任何人。 + "escrowedCent": a.countOne(ctx, `SELECT COALESCE(SUM(gross_cent),0) FROM pet_feed_tasks WHERE status IN ('ESCROWED','ASSIGNED','SERVING','COMPLETED','DISPUTED')`), + "settledCent": a.countOne(ctx, `SELECT COALESCE(SUM(payout_cent),0) FROM pet_feed_tasks WHERE status='SETTLED'`), + "platformFeeCent": a.countOne(ctx, `SELECT COALESCE(SUM(platform_fee_cent),0) FROM pet_feed_tasks WHERE status='SETTLED'`), + "walletBalanceCent": a.countOne(ctx, `SELECT COALESCE(SUM(available_cent),0) FROM wallet_accounts`), + "pendingWithdrawCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals WHERE status IN ('PENDING','APPROVED')`), + "paidWithdrawCent": a.countOne(ctx, `SELECT COALESCE(SUM(payout_cent),0) FROM withdrawals WHERE status='PAID'`), + }, + "scale": map[string]int64{ + "pets": a.countOne(ctx, `SELECT COUNT(*) FROM pets WHERE deleted_at IS NULL`), + "owners": a.countOne(ctx, `SELECT COUNT(DISTINCT owner_user_id) FROM pets WHERE deleted_at IS NULL`), + "activeSitters": a.countOne(ctx, `SELECT COUNT(*) FROM pet_sitters WHERE status=1`), + "openAdoptions": a.countOne(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE status=1 AND moderation_status=1 AND deleted_at IS NULL`), + "handedOver": a.countOne(ctx, `SELECT COUNT(*) FROM pet_adoptions WHERE status=2`), + "dueFollowups": a.countOne(ctx, `SELECT COUNT(*) FROM pet_adoption_followups WHERE submitted_at IS NULL AND due_at<=NOW(3)`), + }, + "switches": map[string]any{ + "feedEnabled": a.configBool(ctx, "pet.feed_enabled", true), + "matingEnabled": a.configBool(ctx, "pet.mating_enabled", false), + "adoptionEnabled": a.configBool(ctx, "pet.adoption_enabled", true), + "withdrawEnabled": a.configBool(ctx, "pet.withdraw_enabled", false), + "platformFeePct": a.configInt(ctx, "pet.feed_platform_fee_percent", 15), + "sitterDepositCent": a.configInt(ctx, "pet.sitter_deposit_cent", 20000), + "confirmHours": a.configInt(ctx, "pet.feed_confirm_hours", 24), + }, + "ledgerMismatches": mismatches, + }) +}