管理端补上运营真正要用的那几件事
原来 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
@@ -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})
|
||||
}
|
||||
Reference in New Issue
Block a user