Files
kefu/im/backend/internal/app/admin.go
T
2026-09-03 08:38:17 +08:00

819 lines
37 KiB
Go

package app
import (
"database/sql"
"encoding/json"
"net/http"
"net/url"
"strconv"
"strings"
"time"
)
func (a *App) adminLogin(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "请输入账号和密码")
return
}
if !a.rateLimit(w, r, "admin_login_ip", clientIP(r), 10, 10*time.Minute) || !a.rateLimit(w, r, "admin_login_user", strings.ToLower(strings.TrimSpace(req.Username)), 10, 15*time.Minute) {
return
}
var id int64
var hash, realName string
var status int
err := a.db.QueryRowContext(r.Context(), `SELECT id,password_hash,real_name,status FROM admin_users WHERE username=?`, req.Username).Scan(&id, &hash, &realName, &status)
if err != nil || !checkPassword(hash, req.Password) {
fail(w, 401, 10001, "账号或密码错误")
return
}
if status != 1 {
fail(w, 403, 10006, "管理员账号已停用")
return
}
payload, err := a.newAdminSession(r.Context(), w, r, id, realName)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "创建管理会话失败")
return
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id)
reply(w, payload)
}
func (a *App) adminChangePassword(w http.ResponseWriter, r *http.Request) {
who := current(r)
if !a.rateLimit(w, r, "admin_password_change", strconv.FormatInt(who.ID, 10), 5, 30*time.Minute) {
return
}
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if decode(r, &req) != nil || req.CurrentPassword == "" || req.NewPassword == "" {
fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码")
return
}
tx, err := a.db.BeginTx(r.Context(), &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
defer func() { _ = tx.Rollback() }()
var passwordHash string
if err = tx.QueryRowContext(r.Context(), `SELECT password_hash FROM admin_users WHERE id=? AND status=1 FOR UPDATE`, who.ID).Scan(&passwordHash); err != nil || !checkPassword(passwordHash, req.CurrentPassword) {
fail(w, http.StatusBadRequest, 20001, "当前密码错误")
return
}
newHash, err := hashPassword(req.NewPassword)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "密码加密失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE admin_users SET password_hash=?,token_version=token_version+1,password_changed_at=NOW(3) WHERE id=?`, newHash, who.ID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE admin_sessions SET revoked_at=NOW(3) WHERE admin_user_id=? AND revoked_at IS NULL`, who.ID); err != nil {
fail(w, http.StatusInternalServerError, 50001, "撤销旧会话失败")
return
}
if err = tx.Commit(); err != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
a.clearAdminRefreshCookie(w)
a.audit(r, "change_password", "admin_user", who.ID, map[string]any{"allSessionsRevoked": true})
reply(w, map[string]bool{"success": true, "reauthenticate": true})
}
func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) {
who := current(r)
var username, realName, avatar string
if err := a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar); err != nil {
fail(w, http.StatusUnauthorized, 10001, "管理员不存在")
return
}
roles, permissions, err := a.adminAccess(r.Context(), who.ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "读取管理员角色失败")
return
}
homePath := "/profile"
permissionHomes := []struct {
Code string
Path string
}{
{"dashboard:view", "/analytics"},
{"users:view", "/users"},
{"content:view", "/posts"},
{"messages:view", "/messages"},
{"reports:handle", "/reports"},
{"risk:view", "/risk"},
{"membership:manage", "/membership"},
{"orders:view", "/orders"},
{"system:manage", "/configs"},
}
permissionSet := map[string]bool{}
for _, permission := range permissions {
permissionSet[permission] = true
}
for _, candidate := range permissionHomes {
if permissionSet[candidate.Code] {
homePath = candidate.Path
break
}
}
reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": roles, "homePath": homePath})
}
func (a *App) dashboard(w http.ResponseWriter, r *http.Request) {
count := func(query string) int64 {
var value int64
_ = a.db.QueryRowContext(r.Context(), query).Scan(&value)
return value
}
amount := func(query string) int64 {
var value sql.NullInt64
_ = a.db.QueryRowContext(r.Context(), query).Scan(&value)
if value.Valid {
return value.Int64
}
return 0
}
trend := []map[string]any{}
for i := 6; i >= 0; i-- {
day := time.Now().AddDate(0, 0, -i)
var users, messages int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&users)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE DATE(created_at)=?`, day.Format("2006-01-02")).Scan(&messages)
trend = append(trend, map[string]any{"date": day.Format("01-02"), "users": users, "messages": messages})
}
reply(w, map[string]any{"metrics": map[string]any{"testUsers": count(`SELECT COUNT(*) FROM users WHERE is_test=1 AND deleted_at IS NULL`), "users": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE is_test=0 AND DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND u.deleted_at IS NULL AND p.last_active_at>DATE_SUB(NOW(),INTERVAL 1 DAY)`), "posts": count(`SELECT COUNT(*) FROM posts WHERE status=1`), "messages": count(`SELECT COUNT(*) FROM im_messages`), "pendingReports": count(`SELECT COUNT(*) FROM reports WHERE status='PENDING'`), "paidOrders": count(`SELECT COUNT(*) FROM orders WHERE status='PAID' AND deleted_at IS NULL`), "revenueCent": amount(`SELECT SUM(amount_cent) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}, "trend": trend, "funnel": []map[string]any{{"name": "注册用户", "value": count(`SELECT COUNT(*) FROM users WHERE is_test=0`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles p JOIN users u ON u.id=p.user_id WHERE u.is_test=0 AND p.profile_score>=80`)}, {"name": "产生互动", "value": count(`SELECT COUNT(DISTINCT user_id) FROM user_likes`)}, {"name": "发起会话", "value": count(`SELECT COUNT(DISTINCT user_id) FROM im_conversation_members`)}, {"name": "付费会员", "value": count(`SELECT COUNT(DISTINCT user_id) FROM orders WHERE status='PAID' AND deleted_at IS NULL`)}}})
}
func (a *App) adminUsers(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
keyword := strings.TrimSpace(r.URL.Query().Get("keyword"))
status, _ := strconv.Atoi(r.URL.Query().Get("status"))
verificationStatus := strings.ToUpper(strings.TrimSpace(r.URL.Query().Get("verificationStatus")))
where := ` WHERE u.deleted_at IS NULL`
args := []any{}
sourceWhere, sourceArgs, sourceErr := testUserFilter(r.URL.Query().Get("userType"), r.URL.Query().Get("testBatch"))
if sourceErr != nil {
fail(w, http.StatusBadRequest, 20001, sourceErr.Error())
return
}
where += sourceWhere
args = append(args, sourceArgs...)
if keyword != "" {
where += ` AND (p.nickname LIKE ? OR u.public_id LIKE ? OR u.phone_hash=?)`
like := "%" + keyword + "%"
args = append(args, like, like, phoneHash(keyword))
}
if status > 0 {
where += ` AND u.status=?`
args = append(args, status)
}
if verificationStatus != "" {
valid := verificationStatus == "UNVERIFIED" || verificationStatus == "PENDING" || verificationStatus == "VERIFIED" || verificationStatus == "REJECTED"
if !valid {
fail(w, http.StatusBadRequest, 20001, "认证状态筛选无效")
return
}
if verificationStatus == "UNVERIFIED" {
where += ` AND NOT EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status<>'UNVERIFIED')`
} else {
where += ` AND EXISTS(SELECT 1 FROM user_verifications vx WHERE vx.user_id=u.id AND vx.verification_type='real_name' AND vx.status=?)`
args = append(args, verificationStatus)
}
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u JOIN user_profiles p ON p.user_id=u.id`+where, args...).Scan(&total)
query := `SELECT u.id,u.public_id,u.phone_cipher,u.status,u.risk_level,u.created_at,u.is_test,u.test_batch,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,p.vip_level,p.last_active_at,COALESCE(v.status,'UNVERIFIED'),(SELECT MAX(s.expires_at) FROM subscriptions s WHERE s.user_id=u.id AND s.status=1 AND s.expires_at>NOW(3)) FROM users u JOIN user_profiles p ON p.user_id=u.id LEFT JOIN user_verifications v ON v.user_id=u.id AND v.verification_type='real_name'` + where + ` ORDER BY u.created_at DESC,u.id DESC LIMIT ? OFFSET ?`
args = append(args, size, offset)
rows, err := a.db.QueryContext(r.Context(), query, args...)
if err != nil {
fail(w, 500, 50001, err.Error())
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var publicID, nickname, avatar, city, verificationStatus string
var phoneCipher []byte
var status, risk, gender, vip, vipLevel int
var created time.Time
var active, vipExpiresAt sql.NullTime
var isTest bool
var testBatch string
if err = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt); err != nil {
fail(w, 500, 50001, "读取用户列表失败")
return
}
phone, _ := a.decryptPhone(phoneCipher)
items = append(items, map[string]any{"id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": maskPhone(phone), "nickname": nickname, "avatar": avatar, "gender": gender, "city": city, "vip": vip > 0, "vipLevel": vipLevel, "vipExpiresAt": nullableTime(vipExpiresAt), "verificationStatus": verificationStatus, "status": status, "riskLevel": risk, "lastActiveAt": nullableTime(active), "createdAt": created})
}
if rows.Err() != nil {
fail(w, 500, 50001, "读取用户列表失败")
return
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminUserDetail(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "invalid id")
return
}
a.loadAdminUserDetail(w, r, id)
}
func (a *App) adminUserStatus(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "invalid id")
return
}
status := 1
action := "unban"
switch {
case strings.HasSuffix(r.URL.Path, "/freeze"):
status = 2
action = "freeze"
case strings.HasSuffix(r.URL.Path, "/ban"):
status = 3
action = "ban"
case strings.HasSuffix(r.URL.Path, "/unfreeze"):
action = "unfreeze"
}
var exists int
if a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE id=? AND deleted_at IS NULL`, id).Scan(&exists) != nil || exists == 0 {
fail(w, 404, 30001, "用户不存在")
return
}
if status != 1 {
typ := "FREEZE"
if status == 3 {
typ = "BAN"
}
if _, err = a.createSanction(r.Context(), current(r).ID, id, typ, "管理员执行账号状态操作", nil); err != nil {
fail(w, 500, 50001, "操作失败")
return
}
} else {
typ := "FREEZE"
if action == "unban" {
typ = "BAN"
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE user_id=? AND sanction_type=? AND status='ACTIVE'`, current(r).ID, id, typ)
var bans, freezes int
_ = a.db.QueryRowContext(r.Context(), `SELECT SUM(sanction_type='BAN'),SUM(sanction_type='FREEZE') FROM user_sanctions WHERE user_id=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, id).Scan(&bans, &freezes)
if bans > 0 {
status = 3
} else if freezes > 0 {
status = 2
}
_, err = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, id)
if err != nil {
fail(w, 500, 50001, "操作失败")
return
}
}
a.audit(r, action, "user", id, map[string]any{"status": status})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminPosts(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE deleted_at IS NULL`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT po.id,po.content,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,p.user_id,p.nickname,p.avatar_url FROM posts po JOIN user_profiles p ON p.user_id=po.user_id WHERE po.deleted_at IS NULL ORDER BY po.created_at DESC LIMIT ? OFFSET ?`, size, offset)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, userID int64
var content, location, nickname, avatar string
var status, moderation, likes, comments int
var created time.Time
_ = rows.Scan(&id, &content, &location, &status, &moderation, &likes, &comments, &created, &userID, &nickname, &avatar)
items = append(items, map[string]any{"id": id, "content": content, "location": location, "status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments, "createdAt": created, "user": map[string]any{"id": userID, "nickname": nickname, "avatar": avatar}})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminPostDetail(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "动态 ID 无效")
return
}
var userID int64
var publicID, nickname, avatar, cityName, content, cityCode, location string
var gender, vip, visibility, status, moderation, likes, comments int
var created, updated time.Time
err = a.db.QueryRowContext(r.Context(), `SELECT po.user_id,u.public_id,p.nickname,p.avatar_url,p.gender,p.city_name,p.is_vip,po.content,po.visibility,po.city_code,po.location_text,po.status,po.moderation_status,po.like_count,po.comment_count,po.created_at,po.updated_at FROM posts po JOIN users u ON u.id=po.user_id JOIN user_profiles p ON p.user_id=po.user_id WHERE po.id=? AND po.deleted_at IS NULL`, id).Scan(&userID, &publicID, &nickname, &avatar, &gender, &cityName, &vip, &content, &visibility, &cityCode, &location, &status, &moderation, &likes, &comments, &created, &updated)
if err == sql.ErrNoRows {
fail(w, 404, 20004, "动态不存在或已下架")
return
}
if err != nil {
fail(w, 500, 50001, "查询动态详情失败")
return
}
media := []map[string]any{}
mediaRows, mediaErr := a.db.QueryContext(r.Context(), `SELECT id,media_url,media_type,sort_order FROM post_media WHERE post_id=? ORDER BY sort_order,id`, id)
if mediaErr == nil {
defer mediaRows.Close()
for mediaRows.Next() {
var mediaID int64
var mediaURL, mediaType string
var sortOrder int
if mediaRows.Scan(&mediaID, &mediaURL, &mediaType, &sortOrder) == nil {
media = append(media, map[string]any{"id": mediaID, "url": mediaURL, "type": mediaType, "sortOrder": sortOrder})
}
}
}
commentItems := []map[string]any{}
commentRows, commentErr := a.db.QueryContext(r.Context(), `SELECT c.id,c.user_id,p.nickname,p.avatar_url,c.parent_comment_id,c.reply_user_id,c.content,c.status,c.moderation_status,c.created_at FROM post_comments c JOIN user_profiles p ON p.user_id=c.user_id WHERE c.post_id=? AND c.deleted_at IS NULL ORDER BY c.created_at DESC LIMIT 100`, id)
if commentErr == nil {
defer commentRows.Close()
for commentRows.Next() {
var commentID, commentUserID int64
var commentNickname, commentAvatar, commentContent string
var parentID, replyUserID sql.NullInt64
var commentStatus, commentModeration int
var commentCreated time.Time
if commentRows.Scan(&commentID, &commentUserID, &commentNickname, &commentAvatar, &parentID, &replyUserID, &commentContent, &commentStatus, &commentModeration, &commentCreated) == nil {
commentItems = append(commentItems, map[string]any{"id": commentID, "userId": commentUserID, "nickname": commentNickname, "avatar": commentAvatar, "parentCommentId": nullableID(parentID), "replyUserId": nullableID(replyUserID), "content": commentContent, "status": commentStatus, "moderationStatus": commentModeration, "createdAt": commentCreated})
}
}
}
likeItems := []map[string]any{}
likeRows, likeErr := a.db.QueryContext(r.Context(), `SELECT l.user_id,p.nickname,p.avatar_url,l.created_at FROM post_likes l JOIN user_profiles p ON p.user_id=l.user_id WHERE l.post_id=? ORDER BY l.created_at DESC LIMIT 100`, id)
if likeErr == nil {
defer likeRows.Close()
for likeRows.Next() {
var likeUserID int64
var likeNickname, likeAvatar string
var likeCreated time.Time
if likeRows.Scan(&likeUserID, &likeNickname, &likeAvatar, &likeCreated) == nil {
likeItems = append(likeItems, map[string]any{"userId": likeUserID, "nickname": likeNickname, "avatar": likeAvatar, "createdAt": likeCreated})
}
}
}
var reportCount int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE target_type='post' AND target_id=?`, id).Scan(&reportCount)
reply(w, map[string]any{
"id": id, "content": content, "visibility": visibility, "cityCode": cityCode, "location": location,
"status": status, "moderationStatus": moderation, "likeCount": likes, "commentCount": comments,
"reportCount": reportCount, "createdAt": created, "updatedAt": updated, "media": media,
"comments": commentItems, "likes": likeItems,
"user": map[string]any{"id": userID, "publicId": publicID, "nickname": nickname, "avatar": avatar, "gender": gender, "city": cityName, "vip": vip == 1},
})
}
func nullableID(value sql.NullInt64) any {
if value.Valid {
return value.Int64
}
return nil
}
func (a *App) adminDeletePost(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "动态编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=? AND status=1`, id)
if err != nil {
fail(w, 500, 50001, "删除失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 404, 30001, "动态不存在或已下架")
return
}
a.audit(r, "delete", "post", id, nil)
reply(w, map[string]bool{"success": true})
}
func (a *App) adminReports(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
status := r.URL.Query().Get("status")
where := ""
args := []any{}
if status != "" {
where = ` WHERE r.status=?`
args = append(args, status)
}
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports r`+where, args...).Scan(&total)
args = append(args, size, offset)
rows, err := a.db.QueryContext(r.Context(), `SELECT r.id,r.reporter_user_id,p.nickname,r.target_type,r.target_id,r.reason_code,r.description,COALESCE(CAST(r.evidence_json AS CHAR CHARACTER SET utf8mb4),'[]'),r.status,r.action_type,r.handle_remark,r.handled_by,COALESCE(handler.real_name,''),r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id LEFT JOIN admin_users handler ON handler.id=r.handled_by`+where+` ORDER BY r.created_at DESC LIMIT ? OFFSET ?`, args...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, reporter, target int64
var nick, targetType, reason, description, evidenceJSON, status, actionType, handleRemark, handlerName string
var handler, handled any
var created time.Time
_ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &handler, &handlerName, &handled, &created)
evidence := []string{}
_ = json.Unmarshal([]byte(evidenceJSON), &evidence)
items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "handledBy": handler, "handlerName": handlerName, "handledAt": handled, "createdAt": created})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminHandleReport(w http.ResponseWriter, r *http.Request) {
id, pathErr := pathID(r)
if pathErr != nil {
fail(w, 400, 20001, "举报编号无效")
return
}
var req struct {
Result string `json:"result"`
Remark string `json:"remark"`
SanctionType string `json:"sanctionType"`
DurationDays int `json:"durationDays"`
}
if decode(r, &req) != nil || (req.Result != "punished" && req.Result != "dismissed") {
fail(w, 400, 20001, "处理结果无效")
return
}
status := "REJECTED"
actionType := "NONE"
if req.Result == "punished" {
status = "PUNISHED"
}
var targetType string
var targetID, reporterID int64
if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id,reporter_user_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID, &reporterID) != nil {
fail(w, 404, 30001, "待处理举报不存在")
return
}
if status == "PUNISHED" {
if len([]rune(strings.TrimSpace(req.Remark))) < 2 {
fail(w, 400, 20001, "请填写明确的处理原因")
return
}
if targetType == "user" {
typ := strings.ToUpper(strings.TrimSpace(req.SanctionType))
if typ == "" {
typ = "WARNING"
}
actionType = typ
if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] {
fail(w, 400, 20001, "处罚类型无效")
return
}
if typ != "WARNING" && typ != "BAN" && (req.DurationDays < 1 || req.DurationDays > 3650) {
fail(w, 400, 20001, "临时处罚期限应为 1-3650 天")
return
}
var expiry *time.Time
if typ != "WARNING" && typ != "BAN" {
value := time.Now().AddDate(0, 0, req.DurationDays)
expiry = &value
}
if _, err := a.createSanction(r.Context(), current(r).ID, targetID, typ, "举报处理:"+strings.TrimSpace(req.Remark), expiry); err != nil {
fail(w, 500, 50001, "处罚执行失败")
return
}
} else if targetType == "post" {
actionType = "CONTENT_REMOVE"
result, actionErr := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=? AND status=1`, targetID)
var affected int64
if actionErr == nil {
affected, _ = result.RowsAffected()
}
if actionErr != nil || affected == 0 {
fail(w, 409, 20001, "动态已下架或不存在")
return
}
} else if targetType == "comment" {
actionType = "CONTENT_REMOVE"
tx, beginErr := a.db.BeginTx(r.Context(), nil)
if beginErr != nil {
fail(w, 500, 50001, "评论处置失败")
return
}
var postID int64
if beginErr = tx.QueryRowContext(r.Context(), `SELECT post_id FROM post_comments WHERE id=? AND status=1 FOR UPDATE`, targetID).Scan(&postID); beginErr == nil {
_, beginErr = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID)
}
if beginErr == nil {
_, beginErr = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID)
}
if beginErr != nil || tx.Commit() != nil {
_ = tx.Rollback()
fail(w, 409, 20001, "评论已删除或不存在")
return
}
} else if targetType == "message" {
actionType = "CONTENT_REMOVE"
var conversationID, seq int64
if a.db.QueryRowContext(r.Context(), `SELECT conversation_id,seq FROM im_messages WHERE id=?`, targetID).Scan(&conversationID, &seq) != nil {
fail(w, 404, 30001, "消息不存在")
return
}
if _, actionErr := a.db.ExecContext(r.Context(), `UPDATE im_messages SET moderation_status=0,admin_removed_at=NOW(3),admin_removed_by=?,admin_remove_reason=? WHERE id=?`, current(r).ID, strings.TrimSpace(req.Remark), targetID); actionErr != nil {
fail(w, 500, 50001, "消息处置失败")
return
}
rows, _ := a.db.QueryContext(r.Context(), `SELECT user_id FROM im_conversation_members WHERE conversation_id=? AND status=1`, conversationID)
members := []int64{}
if rows != nil {
for rows.Next() {
var memberID int64
_ = rows.Scan(&memberID)
members = append(members, memberID)
}
_ = rows.Close()
}
a.hub.broadcast(members, map[string]any{"command": "MESSAGE_RECALLED", "data": map[string]any{"id": targetID, "conversationId": conversationID, "seq": seq}})
} else {
fail(w, 400, 20001, "举报目标类型无效")
return
}
}
result, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,action_type=?,handle_remark=?,handled_by=?,handled_at=NOW(3) WHERE id=? AND status='PENDING'`, status, actionType, strings.TrimSpace(req.Remark), current(r).ID, id)
var affected int64
if err == nil {
affected, _ = result.RowsAffected()
}
if err != nil || affected == 0 {
fail(w, 500, 50001, "处理失败")
return
}
a.audit(r, "handle", "report", id, req)
content := "举报已处理,感谢你帮助维护社区环境。"
if status == "REJECTED" {
content = "本次举报经核查暂未发现违规,感谢你的反馈。"
}
a.notifyUser(r.Context(), reporterID, "system", "举报处理结果", content, "report", id)
reply(w, map[string]bool{"success": true})
}
func (a *App) adminRiskUsers(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
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)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var nick, avatar string
var score, level, message, device, report, behavior int
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})
}
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)
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)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, userID int64
var nick, typ, device, ip string
var delta int
var metadata []byte
var created time.Time
_ = rows.Scan(&id, &userID, &nick, &typ, &delta, &device, &ip, &metadata, &created)
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})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminPlans(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order FROM membership_plans WHERE deleted_at IS NULL ORDER BY sort_order,id`)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []planView{}
for rows.Next() {
var item planView
_ = rows.Scan(&item.ID, &item.Code, &item.Name, &item.Level, &item.DurationDays, &item.DailyActiveChatLimit, &item.DailyLikeLimit, &item.CanViewVisitors, &item.CanInvisibleVisit, &item.RecommendationWeight, &item.PriceCent, &item.OriginalPriceCent, &item.Status, &item.SortOrder)
items = append(items, item)
}
reply(w, map[string]any{"items": items, "total": len(items)})
}
func (a *App) adminCreatePlan(w http.ResponseWriter, r *http.Request) {
var req planView
if decode(r, &req) != nil || strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent {
fail(w, 400, 20001, "请完整填写套餐信息,原价不能低于售价")
return
}
req.Code = strings.ToUpper(strings.TrimSpace(req.Code))
req.Name = strings.TrimSpace(req.Name)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO membership_plans(code,name,level,duration_days,daily_active_chat_limit,daily_like_limit,can_view_visitors,can_invisible_visit,recommendation_weight,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder)
if err != nil {
fail(w, http.StatusConflict, 20001, "套餐编码已存在或数据无效")
return
}
id, _ := result.LastInsertId()
a.audit(r, "create", "membership_plan", id, req)
reply(w, map[string]any{"id": id})
}
func (a *App) adminUpdatePlan(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
var req planView
if err != nil || decode(r, &req) != nil || strings.TrimSpace(req.Name) == "" || req.Level < 1 || req.Level > 20 || req.DurationDays < 1 || req.DailyActiveChatLimit < 0 || req.DailyActiveChatLimit > 10000 || req.DailyLikeLimit < 0 || req.DailyLikeLimit > 100000 || req.RecommendationWeight < 0 || req.RecommendationWeight > 10000 || req.PriceCent < 0 || req.OriginalPriceCent < req.PriceCent {
fail(w, 400, 20001, "套餐信息不完整或价格无效")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE membership_plans SET name=?,level=?,duration_days=?,daily_active_chat_limit=?,daily_like_limit=?,can_view_visitors=?,can_invisible_visit=?,recommendation_weight=?,price_cent=?,original_price_cent=?,status=?,sort_order=? WHERE id=? AND deleted_at IS NULL`, strings.TrimSpace(req.Name), req.Level, req.DurationDays, req.DailyActiveChatLimit, req.DailyLikeLimit, req.CanViewVisitors, req.CanInvisibleVisit, req.RecommendationWeight, req.PriceCent, req.OriginalPriceCent, req.Status, req.SortOrder, id)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 404, 30001, "套餐不存在或已删除")
return
}
a.audit(r, "update", "membership_plan", id, req)
reply(w, map[string]bool{"success": true})
}
func (a *App) adminOrders(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
status := strings.TrimSpace(r.URL.Query().Get("status"))
keyword := strings.TrimSpace(r.URL.Query().Get("keyword"))
where := ` WHERE o.deleted_at IS NULL`
args := []any{}
if status != "" {
where += ` AND o.status=?`
args = append(args, status)
}
if keyword != "" {
where += ` AND (o.order_no LIKE ? OR p.nickname LIKE ? OR u.public_id LIKE ?)`
like := "%" + keyword + "%"
args = append(args, like, like, like)
}
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...)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
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 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})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) adminRefund(w http.ResponseWriter, r *http.Request) {
if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "订单编号无效")
return
}
a.requestLiveRefund(w, r, id)
return
}
a.adminOrderTransition(w, r, "refund")
}
func (a *App) adminConfigs(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT config_key,config_value,value_type,description,updated_at FROM system_configs WHERE config_key NOT LIKE 'sms.%' AND config_key NOT LIKE 'payment.%' AND config_key NOT LIKE 'storage.%' AND config_key NOT LIKE 'oauth.%' ORDER BY config_key`)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var key, value, typ, description string
var updated time.Time
_ = rows.Scan(&key, &value, &typ, &description, &updated)
if typ == "secret" && value != "" {
value = maskedSecret
}
items = append(items, map[string]any{"key": key, "value": value, "type": typ, "description": description, "updatedAt": updated})
}
reply(w, map[string]any{"items": items})
}
func (a *App) adminUpdateConfig(w http.ResponseWriter, r *http.Request) {
parts := strings.Split(strings.Trim(r.URL.Path, "/"), "/")
key, _ := url.PathUnescape(parts[len(parts)-1])
var req struct {
Value string `json:"value"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "invalid value")
return
}
var valueType string
if err := a.db.QueryRowContext(r.Context(), `SELECT value_type FROM system_configs WHERE config_key=?`, key).Scan(&valueType); err != nil {
fail(w, 404, 30001, "配置项不存在")
return
}
if valueType == "secret" {
if req.Value == maskedSecret || req.Value == "" {
reply(w, map[string]bool{"success": true})
return
}
encrypted, err := a.encryptSecret(req.Value)
if err != nil {
fail(w, 500, 50001, "加密敏感配置失败")
return
}
req.Value = encrypted
}
_, err := a.db.ExecContext(r.Context(), `UPDATE system_configs SET config_value=? WHERE config_key=?`, req.Value, key)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
a.audit(r, "update", "system_config", 0, map[string]any{"key": key, "secret": valueType == "secret"})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminAuditLogs(w http.ResponseWriter, r *http.Request) {
page, size, offset := pagination(r)
var total int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM admin_audit_logs`).Scan(&total)
rows, err := a.db.QueryContext(r.Context(), `SELECT l.id,l.admin_user_id,a.real_name,l.action,l.target_type,l.target_id,l.request_data,l.ip,l.created_at FROM admin_audit_logs l JOIN admin_users a ON a.id=l.admin_user_id ORDER BY l.created_at DESC LIMIT ? OFFSET ?`, size, offset)
if err != nil {
fail(w, 500, 50001, "查询失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, adminID int64
var name, action, targetType, ip string
var targetID any
var data []byte
var created time.Time
_ = rows.Scan(&id, &adminID, &name, &action, &targetType, &targetID, &data, &ip, &created)
var request any
_ = json.Unmarshal(data, &request)
items = append(items, map[string]any{"id": id, "adminId": adminID, "adminName": name, "action": action, "targetType": targetType, "targetId": targetID, "request": request, "ip": ip, "createdAt": created})
}
reply(w, pageResult{Items: items, Total: total, Page: page, Size: size})
}
func (a *App) audit(r *http.Request, action, targetType string, targetID int64, data any) {
payload, _ := json.Marshal(data)
var id any
if targetID > 0 {
id = targetID
}
_, _ = a.db.ExecContext(r.Context(), `INSERT INTO admin_audit_logs(admin_user_id,action,target_type,target_id,request_data,ip)VALUES(?,?,?,?,?,?)`, current(r).ID, action, targetType, id, payload, clientIP(r))
}
func maskPhone(value string) string {
if len(value) >= 7 {
return value[:3] + "****" + value[len(value)-4:]
}
return value
}