gengx
This commit is contained in:
@@ -0,0 +1,676 @@
|
||||
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
|
||||
}
|
||||
token, _ := a.token(id, "admin", realName, 8*time.Hour)
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE admin_users SET last_login_at=NOW(3) WHERE id=?`, id)
|
||||
reply(w, map[string]any{"accessToken": token})
|
||||
}
|
||||
func (a *App) adminRefresh(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, map[string]any{"data": "", "status": 0})
|
||||
}
|
||||
func (a *App) adminLogout(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, map[string]bool{"success": true})
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
if !strongAdminPassword(req.NewPassword) {
|
||||
fail(w, http.StatusBadRequest, 20001, "新密码至少 12 位,且必须包含大小写字母、数字和特殊字符")
|
||||
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
|
||||
}
|
||||
if checkPassword(passwordHash, req.NewPassword) {
|
||||
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.Commit(); err != nil {
|
||||
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
|
||||
return
|
||||
}
|
||||
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) adminCodes(w http.ResponseWriter, r *http.Request) {
|
||||
reply(w, []string{"dashboard:view", "users:view", "users:manage", "users:security", "verification:manage", "violations:manage", "content:view", "content:manage", "messages:view", "reports:handle", "risk:view", "membership:manage", "orders:view", "orders:manage", "system:manage"})
|
||||
}
|
||||
func (a *App) adminInfo(w http.ResponseWriter, r *http.Request) {
|
||||
who := current(r)
|
||||
var username, realName, avatar string
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT username,real_name,avatar_url FROM admin_users WHERE id=?`, who.ID).Scan(&username, &realName, &avatar)
|
||||
reply(w, map[string]any{"userId": who.ID, "username": username, "realName": realName, "avatar": avatar, "roles": []string{"super"}, "homePath": "/analytics"})
|
||||
}
|
||||
|
||||
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 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{"users": count(`SELECT COUNT(*) FROM users WHERE deleted_at IS NULL`), "newUsersToday": count(`SELECT COUNT(*) FROM users WHERE DATE(created_at)=CURDATE()`), "activeUsers": count(`SELECT COUNT(*) FROM user_profiles WHERE 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`)}, {"name": "完善资料", "value": count(`SELECT COUNT(*) FROM user_profiles WHERE 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"))
|
||||
where := ` WHERE u.deleted_at IS NULL`
|
||||
args := []any{}
|
||||
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)
|
||||
}
|
||||
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,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` + where + ` ORDER BY u.created_at 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
|
||||
_ = rows.Scan(&id, &publicID, &phoneCipher, &status, &risk, &created, &nickname, &avatar, &gender, &city, &vip, &vipLevel, &active, &verificationStatus, &vipExpiresAt)
|
||||
phone, _ := a.decryptPhone(phoneCipher)
|
||||
items = append(items, map[string]any{"id": id, "publicId": publicID, "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})
|
||||
}
|
||||
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, _ := pathID(r)
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=NOW(3) WHERE id=?`, id)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "删除失败")
|
||||
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 status=?`
|
||||
args = append(args, status)
|
||||
}
|
||||
var total int64
|
||||
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports`+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,r.status,r.handled_by,r.handled_at,r.created_at FROM reports r JOIN user_profiles p ON p.user_id=r.reporter_user_id`+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, status string
|
||||
var handler, handled any
|
||||
var created time.Time
|
||||
_ = rows.Scan(&id, &reporter, &nick, &targetType, &target, &reason, &description, &status, &handler, &handled, &created)
|
||||
items = append(items, map[string]any{"id": id, "reporterId": reporter, "reporterName": nick, "targetType": targetType, "targetId": target, "reason": reason, "description": description, "status": status, "handledBy": handler, "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, _ := pathID(r)
|
||||
var req struct {
|
||||
Result string `json:"result"`
|
||||
Remark string `json:"remark"`
|
||||
SanctionType string `json:"sanctionType"`
|
||||
DurationDays int `json:"durationDays"`
|
||||
}
|
||||
if decode(r, &req) != nil {
|
||||
fail(w, 400, 20001, "invalid result")
|
||||
return
|
||||
}
|
||||
status := "REJECTED"
|
||||
if req.Result == "punished" {
|
||||
status = "PUNISHED"
|
||||
}
|
||||
var targetType string
|
||||
var targetID int64
|
||||
if a.db.QueryRowContext(r.Context(), `SELECT target_type,target_id FROM reports WHERE id=? AND status='PENDING'`, id).Scan(&targetType, &targetID) != nil {
|
||||
fail(w, 404, 30001, "待处理举报不存在")
|
||||
return
|
||||
}
|
||||
if status == "PUNISHED" {
|
||||
if targetType == "user" {
|
||||
typ := strings.ToUpper(strings.TrimSpace(req.SanctionType))
|
||||
if typ == "" {
|
||||
typ = "WARNING"
|
||||
}
|
||||
if !map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}[typ] {
|
||||
fail(w, 400, 20001, "处罚类型无效")
|
||||
return
|
||||
}
|
||||
var expiry *time.Time
|
||||
if req.DurationDays > 0 {
|
||||
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" {
|
||||
_, _ = a.db.ExecContext(r.Context(), `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE id=?`, targetID)
|
||||
}
|
||||
}
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE reports SET status=?,handled_by=?,handled_at=NOW(3) WHERE id=?`, status, current(r).ID, id)
|
||||
if err != nil {
|
||||
fail(w, 500, 50001, "处理失败")
|
||||
return
|
||||
}
|
||||
a.audit(r, "handle", "report", id, req)
|
||||
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,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.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.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,price_cent,original_price_cent,status,sort_order)VALUES(?,?,?,?,?,?,?,?,?)`, req.Code, req.Name, req.Level, req.DurationDays, req.DailyActiveChatLimit, 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.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=?,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.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
|
||||
}
|
||||
Reference in New Issue
Block a user