Files
kefu/im/backend/internal/app/admin_finance.go
T
Your NameandClaude Opus 5 0031f79eb3 管理端补上运营真正要用的那几件事
原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读,
动态和消息只能一条一条点,运营想发一句公告没有任何入口。

公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知,
发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经
读过的假装收回去只会让统计说谎。

资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 +
申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。

退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行;
退完最后一分钱才撤销买到的东西,半份会员仍然是会员。

风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、
跳用户详情,事件可以标记跟进完成。

批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。
处罚仍然一条一条来——批量封禁是错封人最快的方式。

敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定;
转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 13:07:58 +08:00

227 lines
9.7 KiB
Go

package app
import (
"fmt"
"net/http"
"strings"
"time"
)
// The money view operations actually needs: what has been taken in, what is
// still held on someone's behalf, what has been paid out, and whether the two
// sides of the book still agree. Split across four pages it was impossible to
// answer "how much of our balance is other people's money" — that is the
// number this page exists for.
func (a *App) adminFinanceOverview(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
days := 14
if value := strings.TrimSpace(r.URL.Query().Get("days")); value != "" {
if parsed, err := fmt.Sscanf(value, "%d", &days); parsed != 1 || err != nil || days < 1 || days > 90 {
days = 14
}
}
money := map[string]int64{
// 托管中 = 已经从主人那里收到、还没结算也没退的钱。这是平台账上"别人的钱"。
"escrowedCent": a.countOne(ctx, `SELECT COALESCE(SUM(gross_cent-refunded_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'`),
"tipCent": a.countOne(ctx, `SELECT COALESCE(SUM(tip_cent),0) FROM pet_feed_tasks WHERE status IN ('SETTLED','COMPLETED','SERVING','ASSIGNED','ESCROWED')`),
"walletBalanceCent": a.countOne(ctx, `SELECT COALESCE(SUM(available_cent),0) FROM wallet_accounts`),
"pendingPayoutCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM withdrawals WHERE status IN ('PENDING','APPROVED')`),
"paidPayoutCent": a.countOne(ctx, `SELECT COALESCE(SUM(payout_cent),0) FROM withdrawals WHERE status='PAID'`),
"orderPaidCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM orders WHERE status IN ('PAID','REFUNDING') AND deleted_at IS NULL`),
"orderRefundedCent": a.countOne(ctx, `SELECT COALESCE(SUM(refunded_cent),0) FROM orders WHERE deleted_at IS NULL`),
"feedRefundedCent": a.countOne(ctx, `SELECT COALESCE(SUM(refunded_cent),0) FROM pet_feed_tasks`),
"unpaidOrderCent": a.countOne(ctx, `SELECT COALESCE(SUM(amount_cent),0) FROM orders WHERE status='CREATED' AND deleted_at IS NULL`),
"platformOwesCent": a.countOne(ctx, `SELECT COALESCE(SUM(available_cent),0) FROM wallet_accounts`),
"disputeHeldCent": a.countOne(ctx, `SELECT COALESCE(SUM(gross_cent-refunded_cent),0) FROM pet_feed_tasks WHERE status='DISPUTED'`),
"ledgerMismatchUser": 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`),
}
// 按产品看收入:会员、代喂、保证金各自多少,退了多少。
products := []map[string]any{}
rows, err := a.db.QueryContext(ctx, `SELECT product_type,COUNT(*),COALESCE(SUM(amount_cent),0),COALESCE(SUM(refunded_cent),0)
FROM orders WHERE status IN ('PAID','REFUNDING','REFUNDED') AND deleted_at IS NULL GROUP BY product_type`)
if err == nil {
defer rows.Close()
for rows.Next() {
var productType string
var count, amount, refunded int64
if rows.Scan(&productType, &count, &amount, &refunded) == nil {
products = append(products, map[string]any{
"productType": productType, "orderCount": count,
"amountCent": amount, "refundedCent": refunded,
})
}
}
}
// 按日趋势:收进来的和退出去的放在一起看,才能看出某天是不是在净流出。
trend := []map[string]any{}
trendRows, err := a.db.QueryContext(ctx, `SELECT DATE(paid_at) AS day,COALESCE(SUM(amount_cent),0),COUNT(*)
FROM orders WHERE paid_at IS NOT NULL AND paid_at>=DATE_SUB(CURDATE(),INTERVAL ? DAY) AND deleted_at IS NULL
GROUP BY DATE(paid_at) ORDER BY day`, days)
if err == nil {
defer trendRows.Close()
for trendRows.Next() {
var day time.Time
var amount, count int64
if trendRows.Scan(&day, &amount, &count) == nil {
trend = append(trend, map[string]any{
"date": day.Format("2006-01-02"), "amountCent": amount, "orderCount": count,
})
}
}
}
reply(w, map[string]any{
"money": money, "products": products, "trend": trend, "days": days,
"withdrawEnabled": a.configBool(ctx, "pet.withdraw_enabled", false),
"paymentMode": a.configPlain(ctx, "payment.mode", "sandbox"),
})
}
// adminOrderRefunds lists what has already been sent back on one order.
func (a *App) adminOrderRefunds(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
rows, queryErr := a.db.QueryContext(r.Context(), `SELECT f.id,f.amount_cent,f.channel,f.reason,f.created_at,
COALESCE(admin.username,'') FROM order_refunds f LEFT JOIN admin_users admin ON admin.id=f.handled_by
WHERE f.order_id=? ORDER BY f.id DESC`, id)
if queryErr != nil {
fail(w, http.StatusInternalServerError, 50001, "查询退款记录失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var refundID, amount int64
var channel, reason, operator string
var created time.Time
if rows.Scan(&refundID, &amount, &channel, &reason, &created, &operator) != nil {
continue
}
items = append(items, map[string]any{
"id": refundID, "amountCent": amount, "channel": channel, "reason": reason,
"operator": operator, "createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items})
}
// adminRefundOrder is the one refund entry point for every product. A partial
// refund leaves what it bought alone; only refunding the last cent revokes it,
// because half a membership is still a membership.
func (a *App) adminRefundOrder(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "订单编号无效")
return
}
var req struct {
AmountCent int64 `json:"amountCent"`
Reason string `json:"reason"`
Offline bool `json:"offline"`
}
if r.ContentLength > 0 && decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
req.Reason = strings.TrimSpace(req.Reason)
if req.Reason == "" {
req.Reason = "客服操作退款"
}
var userID, productID int64
var amount, refunded int
var orderNo, providerOrderNo, status, productType string
if a.db.QueryRowContext(r.Context(), `SELECT user_id,product_type,product_id,amount_cent,COALESCE(refunded_cent,0),
order_no,provider_order_no,status FROM orders WHERE id=? AND deleted_at IS NULL`, id).
Scan(&userID, &productType, &productID, &amount, &refunded, &orderNo, &providerOrderNo, &status) != nil {
fail(w, http.StatusNotFound, 30001, "订单不存在")
return
}
if status != "PAID" && status != "REFUND_REQUESTED" && status != "REFUNDING" && status != "REFUNDED" {
fail(w, http.StatusBadRequest, 20001, "只有已支付的订单可以退款")
return
}
remaining := int64(amount - refunded)
if remaining <= 0 {
fail(w, http.StatusBadRequest, 20001, "这笔订单已经退完了")
return
}
refundAmount := req.AmountCent
if refundAmount <= 0 {
refundAmount = remaining
}
if refundAmount > remaining {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("最多还能退 %.2f 元", float64(remaining)/100))
return
}
live := a.configPlain(r.Context(), "payment.mode", "sandbox") == "live"
channel := "gateway"
if req.Offline || providerOrderNo == "" || strings.HasPrefix(providerOrderNo, "sandbox:") || !live {
channel = "offline"
}
refundNo := fmt.Sprintf("order:%d:%d", id, time.Now().UnixMilli())
if channel == "gateway" {
if err = a.createGatewayRefund(r.Context(), orderNo, providerOrderNo, refundNo, int(refundAmount)); err != nil {
a.audit(r, "refund_failed", "order", id, map[string]any{"amountCent": refundAmount, "error": err.Error()})
fail(w, http.StatusBadGateway, 50003, "退款网关请求失败,未记账,可以重试或改为线下退款登记")
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
defer func() { _ = tx.Rollback() }()
if _, err = tx.ExecContext(r.Context(), `INSERT INTO order_refunds(order_id,refund_no,user_id,amount_cent,channel,reason,handled_by)
VALUES(?,?,?,?,?,?,?)`, id, refundNo, userID, refundAmount, channel, req.Reason, current(r).ID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
full := int64(refunded)+refundAmount >= int64(amount)
newStatus := status
if full {
newStatus = "REFUNDED"
}
if _, err = tx.ExecContext(r.Context(), `UPDATE orders SET refunded_cent=refunded_cent+?,status=? WHERE id=?`,
refundAmount, newStatus, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
// 退完才收回买到的东西:半份会员仍然是会员,代喂任务同理。
if full {
if err = a.revokePaidOrderTx(r.Context(), tx, id, userID, productType, productID, int(refundAmount)); err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
}
if tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "退款记账失败")
return
}
a.audit(r, "refund", "order", id, map[string]any{
"amountCent": refundAmount, "channel": channel, "full": full, "reason": req.Reason, "refundNo": refundNo,
})
a.notifyUser(r.Context(), userID, "system", "订单已退款",
fmt.Sprintf("订单 %s 已退款 %.2f 元:%s", orderNo, float64(refundAmount)/100, req.Reason), "order", id)
reply(w, map[string]any{
"success": true, "amountCent": refundAmount, "channel": channel,
"refundedCent": int64(refunded) + refundAmount, "remainingCent": remaining - refundAmount, "full": full,
})
}