管理端补上运营真正要用的那几件事

原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读,
动态和消息只能一条一条点,运营想发一句公告没有任何入口。

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 13:07:58 +08:00
co-authored by Claude Opus 5
parent 3e6a4900e2
commit 0031f79eb3
13 changed files with 1589 additions and 23 deletions
+88 -16
View File
@@ -3,6 +3,7 @@ package app
import (
"database/sql"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
@@ -577,9 +578,33 @@ func (a *App) adminHandleReport(w http.ResponseWriter, r *http.Request) {
func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
// 风险分只有配上"这个人现在是什么状态"才能拿来做决定:已经封了的不用再看,
// 加过白的不该再排在最前面。
where := " WHERE 1=1"
args := []any{}
if level := strings.TrimSpace(r.URL.Query().Get("level")); level != "" {
where += " AND rp.risk_level>=?"
args = append(args, level)
}
switch strings.TrimSpace(r.URL.Query().Get("state")) {
case "whitelisted":
where += " AND rp.whitelisted=1"
case "pending":
where += " AND rp.whitelisted=0"
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (p.nickname LIKE ? OR u.public_id LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
}
from := ` FROM user_risk_profiles rp JOIN user_profiles p ON p.user_id=rp.user_id
JOIN users u ON u.id=rp.user_id` + where
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_risk_profiles`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT rp.user_id,p.nickname,p.avatar_url,rp.risk_score,rp.risk_level,rp.message_score,rp.device_score,rp.report_score,rp.behavior_score,rp.updated_at FROM user_risk_profiles rp JOIN user_profiles p ON p.user_id=rp.user_id ORDER BY rp.risk_score DESC LIMIT ? OFFSET ?`, size, offset)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+from, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT rp.user_id,u.public_id,p.nickname,p.avatar_url,rp.risk_score,rp.risk_level,
rp.message_score,rp.device_score,rp.report_score,rp.behavior_score,rp.updated_at,
rp.whitelisted,rp.whitelist_note,u.status`+from+` ORDER BY rp.whitelisted ASC,rp.risk_score DESC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
@@ -588,19 +613,40 @@ func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var id int64
var nick, avatar string
var score, level, message, device, report, behavior int
var publicID, nick, avatar, note string
var score, level, message, device, report, behavior, userStatus int
var whitelisted bool
var updated time.Time
_ = rows.Scan(&id, &nick, &avatar, &score, &level, &message, &device, &report, &behavior, &updated)
items = append(items, map[string]any{"userId": id, "nickname": nick, "avatar": avatar, "riskScore": score, "riskLevel": level, "messageScore": message, "deviceScore": device, "reportScore": report, "behaviorScore": behavior, "updatedAt": updated})
_ = rows.Scan(&id, &publicID, &nick, &avatar, &score, &level, &message, &device, &report, &behavior,
&updated, &whitelisted, &note, &userStatus)
items = append(items, map[string]any{"userId": id, "publicId": publicID, "nickname": nick, "avatar": avatar,
"riskScore": score, "riskLevel": level, "messageScore": message, "deviceScore": device,
"reportScore": report, "behaviorScore": behavior, "updatedAt": updated,
"whitelisted": whitelisted, "whitelistNote": note, "userStatus": userStatus})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminRiskEvents(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
// 事件列表是一份待办:默认先看还没跟进的那些。
where := " WHERE 1=1"
args := []any{}
switch strings.TrimSpace(r.URL.Query().Get("state")) {
case "handled":
where += " AND e.handled_at IS NOT NULL"
case "pending":
where += " AND e.handled_at IS NULL"
}
if eventType := strings.TrimSpace(r.URL.Query().Get("eventType")); eventType != "" {
where += " AND e.event_type=?"
args = append(args, eventType)
}
from := ` FROM risk_events e JOIN user_profiles p ON p.user_id=e.user_id` + where
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM risk_events`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT e.id,e.user_id,p.nickname,e.event_type,e.score_delta,e.device_id,e.ip,e.metadata,e.created_at FROM risk_events e JOIN user_profiles p ON p.user_id=e.user_id ORDER BY e.created_at DESC LIMIT ? OFFSET ?`, size, offset)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*)`+from, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT e.id,e.user_id,p.nickname,e.event_type,e.score_delta,e.device_id,e.ip,
e.metadata,e.created_at,e.handled_at,e.handled_note`+from+` ORDER BY e.handled_at IS NOT NULL,e.created_at DESC LIMIT ? OFFSET ?`,
append(args, size, offset)...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
@@ -609,14 +655,17 @@ func (a *App) adminRiskEvents(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var id, userID int64
var nick, typ, device, ip string
var nick, typ, device, ip, note string
var delta int
var metadata []byte
var created time.Time
_ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created)
var handled any
_ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created, &handled, &note)
var meta any
_ = json.Unmarshal(metadata, &meta)
items = append(items, map[string]any{"id": id, "userId": userID, "nickname": nick, "eventType": typ, "scoreDelta": delta, "deviceId": device, "ip": ip, "metadata": meta, "createdAt": created})
items = append(items, map[string]any{"id": id, "userId": userID, "nickname": nick, "eventType": typ,
"scoreDelta": delta, "deviceId": device, "ip": ip, "metadata": meta, "createdAt": created,
"handledAt": handled, "handledNote": note})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
@@ -689,10 +738,18 @@ func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) {
like := "%" + keyword + "%"
args = append(args, like, like, like)
}
// 一张订单表里现在装着会员、代喂和保证金三种东西,不分开看就没法核对。
if productType := strings.TrimSpace(r.URL.Query().Get("productType")); productType != "" {
where += ` AND o.product_type=?`
args = append(args, productType)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id`+where, args...).Scan(&total)
args = append(args, size, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.user_id,u.public_id,p.nickname,o.product_id,COALESCE(mp.name,''),o.amount_cent,o.currency,o.status,o.channel,o.paid_at,o.created_at FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id LEFT JOIN membership_plans mp ON mp.id=o.product_id`+where+` ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, args...)
rows, err := a.db.QueryContext(r.Context(), `SELECT o.id,o.order_no,o.user_id,u.public_id,p.nickname,o.product_type,o.product_id,
COALESCE(mp.name,''),o.amount_cent,COALESCE(o.refunded_cent,0),o.currency,o.status,o.channel,o.provider_order_no,o.paid_at,o.created_at
FROM orders o JOIN users u ON u.id=o.user_id JOIN user_profiles p ON p.user_id=o.user_id
LEFT JOIN membership_plans mp ON mp.id=o.product_id AND o.product_type='membership'`+where+` ORDER BY o.created_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
@@ -701,12 +758,27 @@ func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) {
items := []map[string]any{}
for rows.Next() {
var id, userID, productID int64
var orderNo, publicID, nickname, product, currency, orderStatus, channel string
var amount int
var orderNo, publicID, nickname, product, currency, orderStatus, channel, productType, providerOrderNo string
var amount, refunded int
var paid any
var created time.Time
_ = rows.Scan(&id, &orderNo, &userID, &publicID, &nickname, &productID, &product, &amount, &currency, &orderStatus, &channel, &paid, &created)
items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created})
_ = rows.Scan(&id, &orderNo, &userID, &publicID, &nickname, &productType, &productID, &product, &amount,
&refunded, &currency, &orderStatus, &channel, &providerOrderNo, &paid, &created)
// 会员订单的名字来自套餐表;代喂和保证金没有套餐,名字要自己拼出来。
switch productType {
case productPetFeed:
product = fmt.Sprintf("代喂任务 #%d", productID)
case productPetDeposit:
product = "喂养者保证金"
case productMembership:
if product == "" {
product = "会员套餐"
}
}
items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID,
"nickname": nickname, "productType": productType, "productId": productID, "productName": product,
"amountCent": amount, "refundedCent": refunded, "currency": currency, "status": orderStatus,
"channel": channel, "providerOrderNo": providerOrderNo, "paidAt": paid, "createdAt": created})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}