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

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

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

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

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

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

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

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

288 lines
11 KiB
Go

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)})
}