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, ¤cy, &orderStatus, &channel, &paid, &created) items = append(items, map[string]any{"id": id, "orderNo": orderNo, "userId": userID, "publicId": publicID, "nickname": nickname, "productId": productID, "productName": product, "amountCent": amount, "currency": currency, "status": orderStatus, "channel": channel, "paidAt": paid, "createdAt": created}) } 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 }