管理端补上运营真正要用的那几件事
原来 107 个后台接口配的是一套只能看的界面:风险中心 6 行代码全是只读, 动态和消息只能一条一条点,运营想发一句公告没有任何入口。 公告与通知:按全体、会员、城市、喂养者、有宠物的用户或指定名单发系统通知, 发之前能看到会发给多少人,可同时走离线推送。撤回只删还没读的那些——已经 读过的假装收回去只会让统计说谎。 资金总览:把"平台账上哪些钱不是自己的"单独摆出来(任务托管中 + 喂养者余额 + 申诉冻结),旁边才是收入、退款和待打款,最后是按产品和按日的收款。 退款统一到一个入口:会员、代喂、保证金都能分几次退到退完,每笔记一行; 退完最后一分钱才撤销买到的东西,半份会员仍然是会员。 风险中心能动手了:按等级和状态筛,行内直接封禁、冻结、加白(必须写理由)、 跳用户详情,事件可以标记跟进完成。 批量处置:动态、消息、举报支持勾选后批量下架/恢复/驳回,一次最多 100 条。 处罚仍然一条一条来——批量封禁是错封人最快的方式。 敏感词统一词库:消息、动态、送养共用一套,命中是拦截还是转人工由运营定; 转人工的命中会在风险中心留一条,管理端还能拿真实文案先试一遍。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
3e6a4900e2
commit
0031f79eb3
@@ -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, ¬e, &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, ¬e)
|
||||
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, ¤cy, &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, ¤cy, &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})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user