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

原来 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})
}
+287
View File
@@ -0,0 +1,287 @@
package app
import (
"context"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
// Announcements. Until now operations had no way to say anything to users:
// an outage, a rule change, a campaign — all of it depended on people noticing
// by themselves. Sending is deliberately recorded and revocable, because the
// two questions afterwards are always "who got it" and "can we take it back".
type broadcastRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Audience string `json:"audience"`
UserIDs []int64 `json:"userIds"`
CityCode string `json:"cityCode"`
WithPush bool `json:"withPush"`
}
// broadcastAudience turns the chosen audience into a recipient query. Keeping
// the SQL here (rather than building it from request fields) is what stops an
// audience picker from turning into an injection point.
func (a *App) broadcastAudience(req broadcastRequest) (string, []any, error) {
switch strings.TrimSpace(req.Audience) {
case "all":
return `SELECT id FROM users WHERE status=1 AND deleted_at IS NULL`, nil, nil
case "vip":
return `SELECT u.id FROM users u JOIN user_profiles p ON p.user_id=u.id
WHERE u.status=1 AND u.deleted_at IS NULL AND p.is_vip=1`, nil, nil
case "city":
code := strings.TrimSpace(req.CityCode)
if code == "" {
return "", nil, fmt.Errorf("请选择城市")
}
return `SELECT u.id FROM users u JOIN user_location_states l ON l.user_id=u.id
WHERE u.status=1 AND u.deleted_at IS NULL AND l.city_code=?`, []any{code}, nil
case "sitters":
return `SELECT u.id FROM users u JOIN pet_sitters s ON s.user_id=u.id
WHERE u.status=1 AND u.deleted_at IS NULL AND s.status=1`, nil, nil
case "owners":
return `SELECT DISTINCT u.id FROM users u JOIN pets p ON p.owner_user_id=u.id
WHERE u.status=1 AND u.deleted_at IS NULL AND p.deleted_at IS NULL`, nil, nil
case "users":
if len(req.UserIDs) == 0 {
return "", nil, fmt.Errorf("请指定接收的用户")
}
if len(req.UserIDs) > 500 {
return "", nil, fmt.Errorf("指定用户最多 500 个,再多请改用人群条件")
}
placeholders := make([]string, 0, len(req.UserIDs))
args := make([]any, 0, len(req.UserIDs))
for _, id := range req.UserIDs {
placeholders = append(placeholders, "?")
args = append(args, id)
}
return `SELECT id FROM users WHERE status=1 AND deleted_at IS NULL AND id IN (` +
strings.Join(placeholders, ",") + `)`, args, nil
}
return "", nil, fmt.Errorf("人群条件无效")
}
func (a *App) adminSendBroadcast(w http.ResponseWriter, r *http.Request) {
var req broadcastRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
req.Title = strings.TrimSpace(req.Title)
req.Content = strings.TrimSpace(req.Content)
if req.Title == "" || len([]rune(req.Title)) > 40 {
fail(w, http.StatusBadRequest, 20001, "标题为 1 到 40 个字")
return
}
if len([]rune(req.Content)) < 5 || len([]rune(req.Content)) > 500 {
fail(w, http.StatusBadRequest, 20001, "正文为 5 到 500 个字")
return
}
query, args, err := a.broadcastAudience(req)
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询接收人失败")
return
}
recipients := []int64{}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
recipients = append(recipients, id)
}
}
rows.Close()
if len(recipients) == 0 {
fail(w, http.StatusBadRequest, 20001, "这个条件下没有匹配到用户")
return
}
limit := a.configInt(r.Context(), "notification.broadcast_max_recipients", 20000)
if len(recipients) > limit {
fail(w, http.StatusBadRequest, 20001, fmt.Sprintf("匹配到 %d 人,超过单次上限 %d 人", len(recipients), limit))
return
}
reference := strings.TrimSpace(req.CityCode)
if req.Audience == "users" {
ids := make([]string, 0, len(req.UserIDs))
for _, id := range req.UserIDs {
ids = append(ids, strconv.FormatInt(id, 10))
}
reference = strings.Join(ids, ",")
if len(reference) > 500 {
reference = reference[:500]
}
}
withPush := req.WithPush && a.configBool(r.Context(), "notification.broadcast_push_enabled", true)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO notification_broadcasts
(title,content,audience,audience_ref,with_push,recipient_count,created_by)
VALUES(?,?,?,?,?,?,?)`, req.Title, req.Content, req.Audience, reference, withPush, len(recipients), current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建公告失败")
return
}
broadcastID, _ := result.LastInsertId()
// 一条一条插会把几万人的公告拖成分钟级,所以按批写;
// 关掉了系统通知的人不发,这是他自己的设置。
delivered := 0
const batchSize = 500
for start := 0; start < len(recipients); start += batchSize {
end := start + batchSize
if end > len(recipients) {
end = len(recipients)
}
values := make([]string, 0, end-start)
params := make([]any, 0, (end-start)*6)
for _, userID := range recipients[start:end] {
if !a.notificationAllowed(r.Context(), userID, "system") {
continue
}
values = append(values, "(?,?,?,?,?,?)")
params = append(params, userID, "system", req.Title, req.Content, "broadcast", broadcastID)
}
if len(values) == 0 {
continue
}
if _, err = a.db.ExecContext(r.Context(), `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) VALUES`+
strings.Join(values, ","), params...); err != nil {
fail(w, http.StatusInternalServerError, 50001, "写入通知失败")
return
}
delivered += len(values)
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE notification_broadcasts SET recipient_count=? WHERE id=?`, delivered, broadcastID)
if withPush {
// 推送是尽力而为:走不通不该让公告本身失败,所以放到后台并单独计数。
go a.pushBroadcast(context.Background(), broadcastID, recipients, req.Title, req.Content)
}
a.audit(r, "broadcast", "notification", broadcastID, map[string]any{
"audience": req.Audience, "recipients": delivered, "withPush": withPush, "title": req.Title,
})
reply(w, map[string]any{"id": broadcastID, "recipientCount": delivered, "withPush": withPush})
}
func (a *App) pushBroadcast(ctx context.Context, broadcastID int64, recipients []int64, title, content string) {
credentials, ok := a.pushSettings(ctx)
if !ok {
return
}
token, err := a.pushToken(ctx, credentials)
if err != nil {
return
}
sent := 0
for _, userID := range recipients {
targets := a.pushRecipients(ctx, 0, 0, []int64{userID})
for _, target := range targets {
if a.sendPush(ctx, credentials, token, target, title, content, 0) == nil {
sent++
}
}
}
_, _ = a.db.ExecContext(ctx, `UPDATE notification_broadcasts SET push_count=? WHERE id=?`, sent, broadcastID)
}
func (a *App) adminBroadcasts(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM notification_broadcasts`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT b.id,b.title,b.content,b.audience,b.audience_ref,b.with_push,
b.recipient_count,b.push_count,b.status,b.revoked_count,b.created_at,COALESCE(admin.username,''),
(SELECT COUNT(*) FROM notifications n WHERE n.biz_type='broadcast' AND n.biz_id=b.id AND n.read_at IS NOT NULL)
FROM notification_broadcasts b LEFT JOIN admin_users admin ON admin.id=b.created_by
ORDER BY b.id DESC LIMIT ? OFFSET ?`, size, offset)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询公告失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var title, content, audience, reference, status, operator string
var withPush bool
var recipients, pushes, revoked, read int
var created time.Time
if rows.Scan(&id, &title, &content, &audience, &reference, &withPush, &recipients, &pushes,
&status, &revoked, &created, &operator, &read) != nil {
continue
}
items = append(items, map[string]any{
"id": id, "title": title, "content": content, "audience": audience, "audienceRef": reference,
"withPush": withPush, "recipientCount": recipients, "pushCount": pushes, "status": status,
"revokedCount": revoked, "readCount": read, "operator": operator,
"createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
}
// adminRevokeBroadcast pulls back only what nobody has read yet. A notification
// someone already opened cannot be unsent, and pretending otherwise would make
// the counts lie.
func (a *App) adminRevokeBroadcast(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var status string
if a.db.QueryRowContext(r.Context(), `SELECT status FROM notification_broadcasts WHERE id=?`, id).Scan(&status) != nil {
fail(w, http.StatusNotFound, 30001, "公告不存在")
return
}
if status == "REVOKED" {
fail(w, http.StatusBadRequest, 20001, "这条公告已经撤回过了")
return
}
result, err := a.db.ExecContext(r.Context(), `DELETE FROM notifications WHERE biz_type='broadcast' AND biz_id=? AND read_at IS NULL`, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "撤回失败")
return
}
revoked, _ := result.RowsAffected()
if _, err = a.db.ExecContext(r.Context(), `UPDATE notification_broadcasts SET status='REVOKED',revoked_count=?,revoked_at=NOW(3) WHERE id=?`, revoked, id); err != nil {
fail(w, http.StatusInternalServerError, 50001, "撤回失败")
return
}
a.audit(r, "revoke_broadcast", "notification", id, map[string]any{"revoked": revoked})
reply(w, map[string]any{"success": true, "revokedCount": revoked})
}
// adminBroadcastPreview answers "how many people would get this" before anyone
// presses send.
func (a *App) adminBroadcastPreview(w http.ResponseWriter, r *http.Request) {
req := broadcastRequest{
Audience: strings.TrimSpace(r.URL.Query().Get("audience")),
CityCode: strings.TrimSpace(r.URL.Query().Get("cityCode")),
}
if raw := strings.TrimSpace(r.URL.Query().Get("userIds")); raw != "" {
for _, part := range strings.Split(raw, ",") {
if id, convErr := strconv.ParseInt(strings.TrimSpace(part), 10, 64); convErr == nil {
req.UserIDs = append(req.UserIDs, id)
}
}
}
query, args, err := a.broadcastAudience(req)
if err != nil {
fail(w, http.StatusBadRequest, 20001, err.Error())
return
}
var count int
if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM (`+query+`) AS audience`, args...).Scan(&count) != nil {
fail(w, http.StatusInternalServerError, 50001, "预览失败")
return
}
reply(w, map[string]any{"count": count, "limit": a.configInt(r.Context(), "notification.broadcast_max_recipients", 20000)})
}
+226
View File
@@ -0,0 +1,226 @@
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,
})
}
@@ -0,0 +1,305 @@
package app
import (
"context"
"encoding/json"
"fmt"
"net/http/httptest"
"strings"
"testing"
)
// 这一批补的都是"管理端原来只能看"的地方,所以断言集中在两件事上:
// 动作真的落到了数据上,以及每个动作都留下了能复核的痕迹。
func TestAdminBroadcastMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
t.Run("an announcement reaches the chosen audience and nobody else", func(t *testing.T) {
if _, err := db.Exec(`UPDATE user_profiles SET is_vip=1 WHERE user_id=2`); err != nil {
t.Fatal(err)
}
preview := httptest.NewRecorder()
a.adminBroadcastPreview(preview, imTestRequest(1, "GET", "/admin/v1/notifications/broadcasts/preview?audience=vip", ""))
if preview.Code != 200 || !strings.Contains(preview.Body.String(), `"count":1`) {
t.Fatalf("会员人群预览应当是 1 人: %s", preview.Body.String())
}
send := httptest.NewRecorder()
a.adminSendBroadcast(send, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts",
`{"title":"会员权益调整","content":"从下周起会员每天的主动搭讪额度提高到 30 次。","audience":"vip"}`))
if send.Code != 200 {
t.Fatalf("HTTP %d: %s", send.Code, send.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
RecipientCount int `json:"recipientCount"`
} `json:"data"`
}
_ = json.Unmarshal(send.Body.Bytes(), &envelope)
if envelope.Data.RecipientCount != 1 {
t.Fatalf("收件人数 = %d", envelope.Data.RecipientCount)
}
var forVIP, forOthers int
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND user_id=2`).Scan(&forVIP); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND user_id<>2`).Scan(&forOthers); err != nil {
t.Fatal(err)
}
if forVIP != 1 || forOthers != 0 {
t.Fatalf("只有会员该收到: vip=%d others=%d", forVIP, forOthers)
}
})
t.Run("empty or oversized announcements are refused", func(t *testing.T) {
for _, body := range []string{
`{"title":"","content":"内容够长了但没有标题","audience":"all"}`,
`{"title":"太短","content":"两个字","audience":"all"}`,
`{"title":"人群不存在","content":"这条应该发不出去","audience":"martians"}`,
`{"title":"没选人","content":"指定用户但一个都没给","audience":"users"}`,
} {
w := httptest.NewRecorder()
a.adminSendBroadcast(w, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts", body))
if w.Code != 400 {
t.Fatalf("应当被拒绝: %s -> HTTP %d", body, w.Code)
}
}
})
t.Run("revoking takes back only what nobody has read", func(t *testing.T) {
send := httptest.NewRecorder()
a.adminSendBroadcast(send, imTestRequest(1, "POST", "/admin/v1/notifications/broadcasts",
`{"title":"系统维护","content":"今晚 23:00 到 24:00 之间服务会短暂中断,给你带来不便很抱歉。","audience":"all"}`))
if send.Code != 200 {
t.Fatalf("HTTP %d: %s", send.Code, send.Body.String())
}
var envelope struct {
Data struct {
ID int64 `json:"id"`
} `json:"data"`
}
_ = json.Unmarshal(send.Body.Bytes(), &envelope)
// 一个人已经读了,撤回不该把他那条也抹掉。
if _, err := db.Exec(`UPDATE notifications SET read_at=NOW(3) WHERE biz_type='broadcast' AND biz_id=? AND user_id=1`, envelope.Data.ID); err != nil {
t.Fatal(err)
}
revoke := httptest.NewRecorder()
a.adminRevokeBroadcast(revoke, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/notifications/broadcasts/%d/revoke", envelope.Data.ID), ``))
if revoke.Code != 200 {
t.Fatalf("HTTP %d: %s", revoke.Code, revoke.Body.String())
}
var left int
if err := db.QueryRow(`SELECT COUNT(*) FROM notifications WHERE biz_type='broadcast' AND biz_id=?`, envelope.Data.ID).Scan(&left); err != nil {
t.Fatal(err)
}
if left != 1 {
t.Fatalf("已读的那条应当留着: 还剩 %d 条", left)
}
again := httptest.NewRecorder()
a.adminRevokeBroadcast(again, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/notifications/broadcasts/%d/revoke", envelope.Data.ID), ``))
if again.Code != 400 {
t.Fatalf("重复撤回应当被拒绝: HTTP %d", again.Code)
}
})
}
func TestAdminKeywordsMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
invalidateKeywordCache()
create := httptest.NewRecorder()
a.adminCreateKeyword(create, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
`{"word":"加我微信","scope":"message","action":"block","category":"引流"}`))
if create.Code != 200 {
t.Fatalf("HTTP %d: %s", create.Code, create.Body.String())
}
duplicate := httptest.NewRecorder()
a.adminCreateKeyword(duplicate, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
`{"word":"加我微信","scope":"message","action":"review"}`))
if duplicate.Code != 400 {
t.Fatalf("同范围下的重复词应当被拒绝: HTTP %d", duplicate.Code)
}
review := httptest.NewRecorder()
a.adminCreateKeyword(review, imTestRequest(1, "POST", "/admin/v1/moderation/keywords",
`{"word":"兼职","scope":"all","action":"review","category":"疑似广告"}`))
if review.Code != 200 {
t.Fatalf("HTTP %d: %s", review.Code, review.Body.String())
}
invalidateKeywordCache()
t.Run("blocking beats review, and scope is respected", func(t *testing.T) {
word, action := a.matchKeyword(context.Background(), "message", "你好,加我微信聊,有兼职")
if action != "block" {
t.Fatalf("同时命中拦截与转人工时应当以拦截为准: %s %s", word, action)
}
word, action = a.matchKeyword(context.Background(), "post", "招人做兼职")
if word != "兼职" || action != "review" {
t.Fatalf("全局词应当在动态里生效: %q %q", word, action)
}
// message 范围的词不该影响动态。
if word, _ = a.matchKeyword(context.Background(), "post", "加我微信"); word != "" {
t.Fatalf("只对消息生效的词泄漏到了动态: %q", word)
}
})
t.Run("a message that trips a blocking word never reaches the database", func(t *testing.T) {
// 用真实的会话入口建会话,免得测试自己拼一套和线上不一样的数据。
var created struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal(imTestCall(t, a.directConversation, 1, "POST", "/api/v1/im/conversations",
`{"userId":2}`, 200), &created); err != nil {
t.Fatal(err)
}
conversation := created.ID
_, _, err := a.persistMessageContext(context.Background(), conversation, 1, "client-block-1", 1,
map[string]any{"text": "加我微信详聊"})
if err == nil {
t.Fatal("命中拦截词的消息不该写进去")
}
var stored int
if queryErr := db.QueryRow(`SELECT COUNT(*) FROM im_messages WHERE conversation_id=?`, conversation).Scan(&stored); queryErr != nil {
t.Fatal(queryErr)
}
if stored != 0 {
t.Fatalf("库里不该有这条消息: %d", stored)
}
// 转人工的词照常发出去,但要在风险中心留一条。
if _, _, err = a.persistMessageContext(context.Background(), conversation, 1, "client-review-1", 1,
map[string]any{"text": "有兼职可以做吗"}); err != nil {
t.Fatalf("转人工的词不该拦下消息: %v", err)
}
var events int
if queryErr := db.QueryRow(`SELECT COUNT(*) FROM risk_events WHERE user_id=1 AND event_type='keyword_message'`).Scan(&events); queryErr != nil {
t.Fatal(queryErr)
}
if events != 1 {
t.Fatalf("风险事件 = %d,转人工的命中应当留痕", events)
}
})
t.Run("the test box answers before anyone complains", func(t *testing.T) {
w := httptest.NewRecorder()
a.adminTestKeyword(w, imTestRequest(1, "POST", "/admin/v1/moderation/keywords/test",
`{"scope":"message","text":"加我微信"}`))
if w.Code != 200 || !strings.Contains(w.Body.String(), `"action":"block"`) {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
})
}
func TestAdminRiskAndBatchMySQL(t *testing.T) {
db := isolatedIMDatabase(t)
a := &App{db: db, hub: NewHub(), config: Config{Environment: "development", JWTSecret: "isolated-im-regression-secret-only"}}
if _, err := db.Exec(`INSERT INTO user_risk_profiles(user_id,risk_score,risk_level) VALUES(2,80,2)`); err != nil {
t.Fatal(err)
}
if _, err := db.Exec(`INSERT INTO risk_events(user_id,event_type,score_delta) VALUES(2,'spam_message',10)`); err != nil {
t.Fatal(err)
}
t.Run("whitelisting needs a reason and is written down", func(t *testing.T) {
bare := httptest.NewRecorder()
a.adminWhitelistRiskUser(bare, imTestRequest(1, "POST", "/admin/v1/risk/users/2/whitelist", `{"on":true}`))
if bare.Code != 400 {
t.Fatalf("加白必须写理由: HTTP %d", bare.Code)
}
w := httptest.NewRecorder()
a.adminWhitelistRiskUser(w, imTestRequest(1, "POST", "/admin/v1/risk/users/2/whitelist",
`{"on":true,"note":"官方运营账号,批量发消息属正常"}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var whitelisted bool
var note string
if err := db.QueryRow(`SELECT whitelisted,whitelist_note FROM user_risk_profiles WHERE user_id=2`).Scan(&whitelisted, &note); err != nil {
t.Fatal(err)
}
if !whitelisted || note == "" {
t.Fatalf("whitelisted=%v note=%q", whitelisted, note)
}
var logged int
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action='risk_whitelist' AND target_id=2`).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged != 1 {
t.Fatalf("加白要留审计: %d", logged)
}
})
t.Run("an event can be closed once", func(t *testing.T) {
var eventID int64
if err := db.QueryRow(`SELECT id FROM risk_events WHERE user_id=2`).Scan(&eventID); err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
a.adminHandleRiskEvent(w, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/risk/events/%d/handle", eventID), `{"note":"核实是正常运营行为"}`))
if w.Code != 200 {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
again := httptest.NewRecorder()
a.adminHandleRiskEvent(again, imTestRequest(1, "POST", fmt.Sprintf("/admin/v1/risk/events/%d/handle", eventID), `{"note":"再点一次"}`))
if again.Code != 400 {
t.Fatalf("重复处理应当被拒绝: HTTP %d", again.Code)
}
})
t.Run("batch actions are capped and leave a trace", func(t *testing.T) {
ids := []int64{}
for index := 0; index < 3; index++ {
result, err := db.Exec(`INSERT INTO posts(user_id,content,visibility) VALUES(1,?,1)`, fmt.Sprintf("批量测试 %d", index))
if err != nil {
t.Fatal(err)
}
id, _ := result.LastInsertId()
ids = append(ids, id)
}
payload, _ := json.Marshal(map[string]any{"ids": ids, "action": "remove", "reason": "批量清理垃圾内容"})
w := httptest.NewRecorder()
a.adminBatchPosts(w, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(payload)))
if w.Code != 200 || !strings.Contains(w.Body.String(), `"affected":3`) {
t.Fatalf("HTTP %d: %s", w.Code, w.Body.String())
}
var live int
if err := db.QueryRow(`SELECT COUNT(*) FROM posts WHERE status=1`).Scan(&live); err != nil {
t.Fatal(err)
}
if live != 0 {
t.Fatalf("还剩 %d 条没下架", live)
}
// 恢复也走同一个入口。
restore, _ := json.Marshal(map[string]any{"ids": ids, "action": "restore"})
back := httptest.NewRecorder()
a.adminBatchPosts(back, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(restore)))
if back.Code != 200 {
t.Fatalf("HTTP %d: %s", back.Code, back.Body.String())
}
// 空选择与超量都要挡住。
empty := httptest.NewRecorder()
a.adminBatchPosts(empty, imTestRequest(1, "POST", "/admin/v1/posts/batch", `{"ids":[],"action":"remove"}`))
if empty.Code != 400 {
t.Fatalf("空勾选应当被拒绝: HTTP %d", empty.Code)
}
many := make([]int64, 101)
for index := range many {
many[index] = int64(index + 1)
}
oversized, _ := json.Marshal(map[string]any{"ids": many, "action": "remove"})
over := httptest.NewRecorder()
a.adminBatchPosts(over, imTestRequest(1, "POST", "/admin/v1/posts/batch", string(oversized)))
if over.Code != 400 {
t.Fatalf("超过 100 条应当被拒绝: HTTP %d", over.Code)
}
var logged int
if err := db.QueryRow(`SELECT COUNT(*) FROM admin_audit_logs WHERE action LIKE 'batch_%'`).Scan(&logged); err != nil {
t.Fatal(err)
}
if logged < 2 {
t.Fatalf("批量操作要留审计: %d", logged)
}
})
}
+247
View File
@@ -0,0 +1,247 @@
package app
import (
"fmt"
"net/http"
"strings"
)
// Two things the console could not do: act on a risk score, and act on more
// than one row at a time. A moderator looking at forty spam posts had to click
// forty times, which is how backlogs turn into "we stopped looking".
// --- 风险中心 -------------------------------------------------------------
func (a *App) adminWhitelistRiskUser(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
On bool `json:"on"`
Note string `json:"note"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
req.Note = strings.TrimSpace(req.Note)
// 加白等于对后面所有告警说"这个人我看过了",没有理由就没法复核。
if req.On && req.Note == "" {
fail(w, http.StatusBadRequest, 20001, "加白必须写明理由")
return
}
if len([]rune(req.Note)) > 200 {
fail(w, http.StatusBadRequest, 20001, "理由过长")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE user_risk_profiles
SET whitelisted=?,whitelist_note=?,reviewed_by=?,reviewed_at=NOW(3) WHERE user_id=?`,
req.On, req.Note, current(r).ID, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "这个用户没有风险档案")
return
}
a.audit(r, "risk_whitelist", "user", id, map[string]any{"on": req.On, "note": req.Note})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminHandleRiskEvent(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req struct {
Note string `json:"note"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
req.Note = strings.TrimSpace(req.Note)
if len([]rune(req.Note)) > 200 {
fail(w, http.StatusBadRequest, 20001, "备注过长")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE risk_events SET handled_at=NOW(3),handled_by=?,handled_note=?
WHERE id=? AND handled_at IS NULL`, current(r).ID, req.Note, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "操作失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusBadRequest, 20001, "这条事件已经处理过了")
return
}
a.audit(r, "risk_event_handled", "risk_event", id, map[string]any{"note": req.Note})
reply(w, map[string]bool{"success": true})
}
// --- 批量处置 -------------------------------------------------------------
type batchRequest struct {
IDs []int64 `json:"ids"`
Action string `json:"action"`
Reason string `json:"reason"`
}
func validBatch(w http.ResponseWriter, req *batchRequest, allowed map[string]bool) bool {
req.Reason = strings.TrimSpace(req.Reason)
if len(req.IDs) == 0 {
fail(w, http.StatusBadRequest, 20001, "请先勾选要处理的内容")
return false
}
// 一次几百条的批量下架,点错一次就要一条条撤回;限量能把误操作的代价压住。
if len(req.IDs) > 100 {
fail(w, http.StatusBadRequest, 20001, "一次最多处理 100 条")
return false
}
if !allowed[req.Action] {
fail(w, http.StatusBadRequest, 20001, "操作无效")
return false
}
if len([]rune(req.Reason)) > 200 {
fail(w, http.StatusBadRequest, 20001, "原因过长")
return false
}
return true
}
func placeholdersFor(ids []int64) (string, []any) {
marks := make([]string, 0, len(ids))
args := make([]any, 0, len(ids))
for _, id := range ids {
marks = append(marks, "?")
args = append(args, id)
}
return strings.Join(marks, ","), args
}
func (a *App) adminBatchPosts(w http.ResponseWriter, r *http.Request) {
var req batchRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
if !validBatch(w, &req, map[string]bool{"remove": true, "restore": true}) {
return
}
marks, args := placeholdersFor(req.IDs)
query := `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE status=1 AND id IN (` + marks + `)`
if req.Action == "restore" {
query = `UPDATE posts SET status=1,deleted_at=NULL WHERE status=0 AND id IN (` + marks + `)`
}
result, err := a.db.ExecContext(r.Context(), query, args...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "批量操作失败")
return
}
affected, _ := result.RowsAffected()
a.audit(r, "batch_"+req.Action, "post", 0, map[string]any{"ids": req.IDs, "affected": affected, "reason": req.Reason})
reply(w, map[string]any{"success": true, "affected": affected})
}
func (a *App) adminBatchMessages(w http.ResponseWriter, r *http.Request) {
var req batchRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
if !validBatch(w, &req, map[string]bool{"remove": true, "restore": true}) {
return
}
if req.Action == "remove" && req.Reason == "" {
fail(w, http.StatusBadRequest, 20001, "批量下架消息必须写明原因")
return
}
marks, args := placeholdersFor(req.IDs)
query := `UPDATE im_messages SET admin_removed_at=NOW(3),admin_remove_reason=? WHERE admin_removed_at IS NULL AND id IN (` + marks + `)`
params := append([]any{req.Reason}, args...)
if req.Action == "restore" {
query = `UPDATE im_messages SET admin_removed_at=NULL,admin_remove_reason='' WHERE admin_removed_at IS NOT NULL AND id IN (` + marks + `)`
params = args
}
result, err := a.db.ExecContext(r.Context(), query, params...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "批量操作失败")
return
}
affected, _ := result.RowsAffected()
a.audit(r, "batch_"+req.Action, "message", 0, map[string]any{"ids": req.IDs, "affected": affected, "reason": req.Reason})
reply(w, map[string]any{"success": true, "affected": affected})
}
// adminBatchReports only closes reports without punishing anyone. Sanctions
// stay one at a time on purpose: batching a punishment is how the wrong person
// gets banned.
func (a *App) adminBatchReports(w http.ResponseWriter, r *http.Request) {
var req batchRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
if !validBatch(w, &req, map[string]bool{"dismiss": true}) {
return
}
if req.Reason == "" {
fail(w, http.StatusBadRequest, 20001, "批量驳回必须写明理由")
return
}
marks, args := placeholdersFor(req.IDs)
params := append([]any{req.Reason, current(r).ID}, args...)
result, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status='REJECTED',action_type='NONE',handle_remark=?,
handled_by=?,handled_at=NOW(3) WHERE status='PENDING' AND id IN (`+marks+`)`, params...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "批量操作失败")
return
}
affected, _ := result.RowsAffected()
a.audit(r, "batch_dismiss", "report", 0, map[string]any{"ids": req.IDs, "affected": affected, "reason": req.Reason})
reply(w, map[string]any{"success": true, "affected": affected})
}
// adminBatchPetListings mirrors the same shape for the pet module queue.
func (a *App) adminBatchPetListings(w http.ResponseWriter, r *http.Request) {
var req struct {
batchRequest
Kind string `json:"kind"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "参数错误")
return
}
table, ok := petListingTables[strings.TrimSpace(req.Kind)]
if !ok {
fail(w, http.StatusBadRequest, 20001, "类型无效")
return
}
if !validBatch(w, &req.batchRequest, map[string]bool{"approve": true, "reject": true}) {
return
}
if req.Action == "reject" && req.Reason == "" {
fail(w, http.StatusBadRequest, 20001, "批量驳回必须写明原因")
return
}
marks, args := placeholdersFor(req.IDs)
status := 1
if req.Action == "reject" {
status = 2
}
params := append([]any{status, req.Reason}, args...)
result, err := a.db.ExecContext(r.Context(), fmt.Sprintf(
`UPDATE %s SET moderation_status=?,moderation_note=? WHERE moderation_status=0 AND deleted_at IS NULL AND id IN (%s)`,
table, marks), params...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "批量操作失败")
return
}
affected, _ := result.RowsAffected()
a.audit(r, "batch_"+req.Action, "pet_"+req.Kind, 0, map[string]any{"ids": req.IDs, "affected": affected, "reason": req.Reason})
reply(w, map[string]any{"success": true, "affected": affected})
}
+18 -1
View File
@@ -353,6 +353,17 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodPost, Path: "/admin/v1/reports/:id/handle", Handler: permit("reports:handle", a.adminHandleReport)},
{Method: http.MethodGet, Path: "/admin/v1/risk/users", Handler: permit("risk:view", a.adminRiskUsers)},
{Method: http.MethodGet, Path: "/admin/v1/risk/events", Handler: permit("risk:view", a.adminRiskEvents)},
{Method: http.MethodPost, Path: "/admin/v1/risk/users/:id/whitelist", Handler: permit("risk:view", a.adminWhitelistRiskUser)},
{Method: http.MethodPost, Path: "/admin/v1/risk/events/:id/handle", Handler: permit("risk:view", a.adminHandleRiskEvent)},
{Method: http.MethodPost, Path: "/admin/v1/posts/batch", Handler: permit("content:manage", a.adminBatchPosts)},
{Method: http.MethodPost, Path: "/admin/v1/messages/batch", Handler: permit("messages:manage", a.adminBatchMessages)},
{Method: http.MethodPost, Path: "/admin/v1/reports/batch", Handler: permit("reports:handle", a.adminBatchReports)},
{Method: http.MethodPost, Path: "/admin/v1/pet/listings/batch", Handler: permit("users:manage", a.adminBatchPetListings)},
{Method: http.MethodGet, Path: "/admin/v1/moderation/keywords", Handler: permit("system:manage", a.adminKeywords)},
{Method: http.MethodPost, Path: "/admin/v1/moderation/keywords", Handler: permit("system:manage", a.adminCreateKeyword)},
{Method: http.MethodPut, Path: "/admin/v1/moderation/keywords/:id", Handler: permit("system:manage", a.adminUpdateKeyword)},
{Method: http.MethodDelete, Path: "/admin/v1/moderation/keywords/:id", Handler: permit("system:manage", a.adminDeleteKeyword)},
{Method: http.MethodPost, Path: "/admin/v1/moderation/keywords/test", Handler: permit("system:manage", a.adminTestKeyword)},
{Method: http.MethodGet, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminPlans)},
{Method: http.MethodPost, Path: "/admin/v1/membership/plans", Handler: permit("membership:manage", a.adminCreatePlan)},
{Method: http.MethodPatch, Path: "/admin/v1/membership/plans/:id", Handler: permit("membership:manage", a.adminUpdatePlan)},
@@ -363,7 +374,13 @@ func (a *App) adminRoutes() []rest.Route {
{Method: http.MethodDelete, Path: "/admin/v1/orders/:id", Handler: permit("orders:manage", a.adminDeleteOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/pay", Handler: permit("orders:manage", a.adminMarkOrderPaid)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/close", Handler: permit("orders:manage", a.adminCloseOrder)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefund)},
{Method: http.MethodPost, Path: "/admin/v1/orders/:id/refund", Handler: permit("orders:manage", a.adminRefundOrder)},
{Method: http.MethodGet, Path: "/admin/v1/orders/:id/refunds", Handler: permit("orders:view", a.adminOrderRefunds)},
{Method: http.MethodGet, Path: "/admin/v1/finance/overview", Handler: permit("orders:view", a.adminFinanceOverview)},
{Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:view", a.adminBroadcasts)},
{Method: http.MethodGet, Path: "/admin/v1/notifications/broadcasts/preview", Handler: permit("users:view", a.adminBroadcastPreview)},
{Method: http.MethodPost, Path: "/admin/v1/notifications/broadcasts", Handler: permit("users:manage", a.adminSendBroadcast)},
{Method: http.MethodPost, Path: "/admin/v1/notifications/broadcasts/:id/revoke", Handler: permit("users:manage", a.adminRevokeBroadcast)},
{Method: http.MethodGet, Path: "/admin/v1/messages", Handler: permit("messages:view", a.adminMessages)},
{Method: http.MethodPost, Path: "/admin/v1/messages/:id/moderate", Handler: permit("messages:manage", a.adminModerateMessage)},
{Method: http.MethodGet, Path: "/admin/v1/pets", Handler: permit("users:view", a.adminPets)},
+298
View File
@@ -0,0 +1,298 @@
package app
import (
"context"
"net/http"
"strings"
"sync"
"time"
)
// One keyword list for the whole product. Before this, messages had no check at
// all, posts had none, and adoption had its own comma-separated config string —
// three different answers to the same question. The list is cached because it
// is read on every message and written a few times a week.
type contentKeyword struct {
ID int64
Word string
Scope string
Action string
}
type keywordCache struct {
mutex sync.RWMutex
words []contentKeyword
loadedAt time.Time
}
var keywords = &keywordCache{}
const keywordCacheTTL = time.Minute
func (a *App) loadKeywords(ctx context.Context) []contentKeyword {
keywords.mutex.RLock()
if time.Since(keywords.loadedAt) < keywordCacheTTL {
defer keywords.mutex.RUnlock()
return keywords.words
}
keywords.mutex.RUnlock()
rows, err := a.db.QueryContext(ctx, `SELECT id,word,scope,action FROM content_keywords WHERE status=1`)
if err != nil {
return nil
}
defer rows.Close()
loaded := []contentKeyword{}
for rows.Next() {
var item contentKeyword
if rows.Scan(&item.ID, &item.Word, &item.Scope, &item.Action) == nil && strings.TrimSpace(item.Word) != "" {
item.Word = strings.ToLower(strings.TrimSpace(item.Word))
loaded = append(loaded, item)
}
}
keywords.mutex.Lock()
keywords.words = loaded
keywords.loadedAt = time.Now()
keywords.mutex.Unlock()
return loaded
}
func invalidateKeywordCache() {
keywords.mutex.Lock()
keywords.loadedAt = time.Time{}
keywords.mutex.Unlock()
}
// matchKeyword returns the first word this text trips and what to do about it.
// A "block" anywhere wins over a "review": the stricter rule is the one the
// operator meant.
func (a *App) matchKeyword(ctx context.Context, scope, text string) (string, string) {
if strings.TrimSpace(text) == "" || !a.configBool(ctx, "moderation.keywords_enabled", true) {
return "", ""
}
lowered := strings.ToLower(text)
hit, action := "", ""
for _, item := range a.loadKeywords(ctx) {
if item.Scope != "all" && item.Scope != scope {
continue
}
if !strings.Contains(lowered, item.Word) {
continue
}
if item.Action == "block" {
a.countKeywordHit(ctx, item.ID)
return item.Word, "block"
}
if hit == "" {
hit, action = item.Word, "review"
}
}
if hit != "" {
for _, item := range a.loadKeywords(ctx) {
if item.Word == hit {
a.countKeywordHit(ctx, item.ID)
break
}
}
}
return hit, action
}
// 命中次数是运营调词库的依据:一个从来不命中的词和一个天天命中的词,价值不一样。
func (a *App) countKeywordHit(ctx context.Context, id int64) {
go func() {
background, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
_, _ = a.db.ExecContext(background, `UPDATE content_keywords SET hit_count=hit_count+1,last_hit_at=NOW(3) WHERE id=?`, id)
}()
}
// --- 管理端 ---------------------------------------------------------------
func (a *App) adminKeywords(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
where := " WHERE 1=1"
args := []any{}
if scope := strings.TrimSpace(r.URL.Query().Get("scope")); scope != "" {
where += " AND scope=?"
args = append(args, scope)
}
if action := strings.TrimSpace(r.URL.Query().Get("action")); action != "" {
where += " AND action=?"
args = append(args, action)
}
if keyword := strings.TrimSpace(r.URL.Query().Get("keyword")); keyword != "" {
where += " AND (word LIKE ? OR category LIKE ?)"
like := "%" + keyword + "%"
args = append(args, like, like)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM content_keywords`+where, args...).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT id,word,scope,action,category,note,status,hit_count,last_hit_at,created_at
FROM content_keywords`+where+` ORDER BY hit_count DESC,id DESC LIMIT ? OFFSET ?`, append(args, size, offset)...)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询词库失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var word, scope, action, category, note string
var status, hits int
var lastHit any
var created time.Time
if rows.Scan(&id, &word, &scope, &action, &category, &note, &status, &hits, &lastHit, &created) != nil {
continue
}
items = append(items, map[string]any{
"id": id, "word": word, "scope": scope, "action": action, "category": category,
"note": note, "status": status, "hitCount": hits, "lastHitAt": lastHit,
"createdAt": created.Format(time.RFC3339),
})
}
reply(w, map[string]any{"items": items, "total": total, "page": page, "size": size})
}
type keywordRequest struct {
Word string `json:"word"`
Scope string `json:"scope"`
Action string `json:"action"`
Category string `json:"category"`
Note string `json:"note"`
Status int `json:"status"`
}
var keywordScopes = map[string]bool{"all": true, "message": true, "post": true, "adoption": true, "nickname": true}
func validKeyword(req *keywordRequest) string {
req.Word = strings.TrimSpace(req.Word)
req.Category = strings.TrimSpace(req.Category)
req.Note = strings.TrimSpace(req.Note)
if req.Scope == "" {
req.Scope = "all"
}
if req.Action == "" {
req.Action = "review"
}
if len([]rune(req.Word)) < 1 || len([]rune(req.Word)) > 30 {
return "关键词为 1 到 30 个字"
}
if !keywordScopes[req.Scope] {
return "适用范围无效"
}
if req.Action != "block" && req.Action != "review" {
return "命中动作只能是拦截或转人工"
}
if len([]rune(req.Category)) > 20 || len([]rune(req.Note)) > 100 {
return "分类或备注过长"
}
return ""
}
func (a *App) adminCreateKeyword(w http.ResponseWriter, r *http.Request) {
var req keywordRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if message := validKeyword(&req); message != "" {
fail(w, http.StatusBadRequest, 20001, message)
return
}
result, err := a.db.ExecContext(r.Context(), `INSERT INTO content_keywords(word,scope,action,category,note,status,created_by)
VALUES(?,?,?,?,?,1,?)`, req.Word, req.Scope, req.Action, req.Category, req.Note, current(r).ID)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "这个词在该范围下已经存在")
return
}
id, _ := result.LastInsertId()
invalidateKeywordCache()
a.audit(r, "create", "content_keyword", id, map[string]any{"word": req.Word, "scope": req.Scope, "action": req.Action})
reply(w, map[string]any{"id": id})
}
func (a *App) adminUpdateKeyword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
var req keywordRequest
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if message := validKeyword(&req); message != "" {
fail(w, http.StatusBadRequest, 20001, message)
return
}
status := 1
if req.Status == 0 {
status = 0
}
result, err := a.db.ExecContext(r.Context(), `UPDATE content_keywords SET word=?,scope=?,action=?,category=?,note=?,status=? WHERE id=?`,
req.Word, req.Scope, req.Action, req.Category, req.Note, status, id)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "保存失败,可能与已有词重复")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "词条不存在")
return
}
invalidateKeywordCache()
a.audit(r, "update", "content_keyword", id, map[string]any{"word": req.Word, "status": status})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminDeleteKeyword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `DELETE FROM content_keywords WHERE id=?`, id)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "删除失败")
return
}
if affected, _ := result.RowsAffected(); affected == 0 {
fail(w, http.StatusNotFound, 30001, "词条不存在")
return
}
invalidateKeywordCache()
a.audit(r, "delete", "content_keyword", id, nil)
reply(w, map[string]bool{"success": true})
}
// adminTestKeyword lets an operator paste a real message and see what the list
// would do with it, instead of finding out from a complaint.
func (a *App) adminTestKeyword(w http.ResponseWriter, r *http.Request) {
var req struct {
Scope string `json:"scope"`
Text string `json:"text"`
}
if decode(r, &req) != nil {
fail(w, http.StatusBadRequest, 20001, "格式错误")
return
}
if req.Scope == "" {
req.Scope = "message"
}
word, action := a.matchKeyword(r.Context(), req.Scope, req.Text)
reply(w, map[string]any{"word": word, "action": action, "matched": word != ""})
}
// recordKeywordRisk turns a soft keyword hit into something visible: the risk
// centre is where a moderator would look, so that is where it goes.
func (a *App) recordKeywordRisk(ctx context.Context, userID int64, scope, word string) {
if userID <= 0 {
return
}
_, _ = a.db.ExecContext(ctx, `INSERT INTO risk_events(user_id,event_type,score_delta,metadata)
VALUES(?,?,?,JSON_OBJECT('scope',?,'word',?))`, userID, "keyword_"+scope, 2, scope, word)
_, _ = a.db.ExecContext(ctx, `INSERT INTO user_risk_profiles(user_id,risk_score,message_score)
VALUES(?,2,2) ON DUPLICATE KEY UPDATE risk_score=risk_score+2,message_score=message_score+2`, userID)
}
+10
View File
@@ -604,6 +604,16 @@ func (a *App) persistMessageContext(ctx context.Context, conversationID, senderI
if err = a.ensureMessageMembership(ctx, senderID, messageType); err != nil {
return item, nil, err
}
// 词库对文字消息生效:拦截词直接发不出去,转人工的词放行但记一条风险事件,
// 免得把用户挡在门外却没人知道发生过什么。
if messageType == 1 {
if word, action := a.matchKeyword(ctx, "message", string(body)); word != "" {
if action == "block" {
return item, nil, fmt.Errorf("消息包含平台不允许的内容")
}
a.recordKeywordRisk(ctx, senderID, "message", word)
}
}
var mediaAssetID int64
if messageType == 2 || messageType == 3 {
contentMap, _ := content.(map[string]any)
+4
View File
@@ -56,6 +56,10 @@ var adoptionQuestions = []struct {
}
func (a *App) adoptionReviewReason(ctx context.Context, ownerID int64, text string) string {
// 统一词库优先;配置里那份旧词表留着兜底,免得词库被清空后送养就没人看了。
if word, _ := a.matchKeyword(ctx, "adoption", text); word != "" {
return "文案命中敏感词:" + word
}
words := strings.Split(a.configPlain(ctx, "pet.adoption_review_words", ""), ",")
lowered := strings.ToLower(text)
for _, word := range words {
+5 -5
View File
@@ -14,11 +14,11 @@ import (
// starting point, not a rule.
type priceSuggestConfig struct {
Base map[string]int `json:"base"` // 物种基准单价(分)
Cities map[string]int `json:"cities"` // 城市系数(百分比),键是城市编码前两位或全码
Minutes map[string]int `json:"minutes"` // 每次时长加价(分)
DogWalk int `json:"dogWalk"` // 含遛狗时的额外加价(分)
Rounding int `json:"rounding"` // 取整到多少分
Base map[string]int `json:"base"` // 物种基准单价(分)
Cities map[string]int `json:"cities"` // 城市系数(百分比),键是城市编码前两位或全码
Minutes map[string]int `json:"minutes"` // 每次时长加价(分)
DogWalk int `json:"dogWalk"` // 含遛狗时的额外加价(分)
Rounding int `json:"rounding"` // 取整到多少分
}
const defaultPriceSuggest = `{
+11 -1
View File
@@ -564,6 +564,16 @@ func (a *App) createPost(w http.ResponseWriter, r *http.Request) {
if req.Visibility != 2 {
req.Visibility = 1
}
// 动态和消息共用一套词库:拦截词直接拒绝,转人工的词先发出去但排进审核队列。
moderation := 1
if word, action := a.matchKeyword(r.Context(), "post", req.Content); word != "" {
if action == "block" {
fail(w, 400, 20001, "动态包含平台不允许的内容")
return
}
moderation = 0
a.recordKeywordRisk(r.Context(), current(r).ID, "post", word)
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "发布失败")
@@ -578,7 +588,7 @@ func (a *App) createPost(w http.ResponseWriter, r *http.Request) {
return
}
}
result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text) VALUES (?,?,?,'310100',?)`, current(r).ID, req.Content, req.Visibility, req.Location)
result, err := tx.ExecContext(r.Context(), `INSERT INTO posts (user_id,content,visibility,city_code,location_text,moderation_status) VALUES (?,?,?,'310100',?,?)`, current(r).ID, req.Content, req.Visibility, req.Location, moderation)
if err != nil {
_ = tx.Rollback()
fail(w, 500, 50001, "发布失败")
@@ -0,0 +1,43 @@
SET NAMES utf8mb4;
-- 公告与系统通知群发。发出去的每一条都记一行,因为"给谁发过什么"是事后
-- 唯一能对账的东西;撤回也只撤这一批里还没读的那些。
CREATE TABLE IF NOT EXISTS notification_broadcasts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
title VARCHAR(100) NOT NULL,
content VARCHAR(1000) NOT NULL,
audience VARCHAR(20) NOT NULL COMMENT 'all/users/vip/city/sitters/owners',
audience_ref VARCHAR(500) NOT NULL DEFAULT '' COMMENT '城市编码或用户编号列表',
with_push TINYINT(1) NOT NULL DEFAULT 0,
recipient_count INT UNSIGNED NOT NULL DEFAULT 0,
push_count INT UNSIGNED NOT NULL DEFAULT 0,
status VARCHAR(20) NOT NULL DEFAULT 'SENT' COMMENT 'SENT/REVOKED',
revoked_count INT UNSIGNED NOT NULL DEFAULT 0,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
revoked_at DATETIME(3) NULL,
PRIMARY KEY (id),
KEY idx_broadcast_created (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 订单退款流水。代喂那张 pet_feed_refunds 记的是任务口径,这张记的是订单
-- 口径:会员、保证金、代喂都在这里,一笔订单可以退很多次。
CREATE TABLE IF NOT EXISTS order_refunds (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
refund_no VARCHAR(60) NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
amount_cent INT UNSIGNED NOT NULL,
channel VARCHAR(20) NOT NULL DEFAULT 'gateway' COMMENT 'gateway 原路退回 / offline 线下已退',
reason VARCHAR(500) NOT NULL DEFAULT '',
handled_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_order_refund_no (refund_no),
KEY idx_order_refunds_order (order_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
('notification.broadcast_max_recipients','20000','integer','单次公告的最大接收人数,超过要分批发'),
('notification.broadcast_push_enabled','true','boolean','公告是否允许同时走离线推送')
ON DUPLICATE KEY UPDATE description=VALUES(description);
@@ -0,0 +1,47 @@
SET NAMES utf8mb4;
-- 风险中心原来只能看。加白是"这个人我们看过了,别再报",事件处理是"这条我
-- 跟进完了",两件事都得留下是谁做的。
ALTER TABLE user_risk_profiles
ADD COLUMN whitelisted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '人工加白后不再进风险列表',
ADD COLUMN whitelist_note VARCHAR(255) NOT NULL DEFAULT '',
ADD COLUMN reviewed_by BIGINT UNSIGNED NULL,
ADD COLUMN reviewed_at DATETIME(3) NULL;
ALTER TABLE risk_events
ADD COLUMN handled_at DATETIME(3) NULL,
ADD COLUMN handled_by BIGINT UNSIGNED NULL,
ADD COLUMN handled_note VARCHAR(255) NOT NULL DEFAULT '',
ADD KEY idx_risk_events_handled (handled_at, id);
-- 统一词库:消息、动态、送养原来各有各的判断(送养那组还写在配置里)。
-- 命中之后是拦下来还是转人工,是运营的判断,所以写在数据里而不是代码里。
CREATE TABLE IF NOT EXISTS content_keywords (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
word VARCHAR(60) NOT NULL,
scope VARCHAR(20) NOT NULL DEFAULT 'all' COMMENT 'all/message/post/adoption/nickname',
action VARCHAR(10) NOT NULL DEFAULT 'review' COMMENT 'block 直接拦下 / review 转人工',
category VARCHAR(30) NOT NULL DEFAULT '' COMMENT '分类,便于成组开关',
note VARCHAR(200) NOT NULL DEFAULT '',
status TINYINT UNSIGNED NOT NULL DEFAULT 1 COMMENT '0 停用 1 启用',
hit_count INT UNSIGNED NOT NULL DEFAULT 0,
last_hit_at DATETIME(3) NULL,
created_by BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (id),
UNIQUE KEY uk_keyword_scope (word, scope),
KEY idx_keywords_status (status, scope)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
-- 把送养那组已经在用的词搬进词库,行为保持原样(转人工,不直接拦)。
INSERT IGNORE INTO content_keywords(word,scope,action,category,note)
SELECT TRIM(word),'adoption','review','送养收费','从 pet.adoption_review_words 迁移' FROM (
SELECT '' AS word UNION ALL SELECT '' UNION ALL SELECT '' UNION ALL SELECT '押金'
UNION ALL SELECT '费用' UNION ALL SELECT '红包' UNION ALL SELECT '转账' UNION ALL SELECT '收费'
UNION ALL SELECT '出售' UNION ALL SELECT '' UNION ALL SELECT ''
) AS legacy;
INSERT INTO system_configs(config_key,config_value,value_type,description) VALUES
('moderation.keywords_enabled','true','boolean','是否启用统一词库;关闭后消息与动态不做关键词判断')
ON DUPLICATE KEY UPDATE description=VALUES(description);