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

573 lines
25 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
func (a *App) loadAdminUserDetail(w http.ResponseWriter, r *http.Request, id int64) {
var publicID, nickname, avatar, cover, cityCode, city, occupation, bio string
var phoneCipher []byte
var birthday sql.NullString
var created time.Time
var lastActive sql.NullTime
var status, risk, gender, height, profileScore, vip, vipLevel int
var followingCount, followerCount, postCount, likeCount int
var isTest bool
var testBatch string
err := a.db.QueryRowContext(r.Context(), `SELECT 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.cover_url,p.gender,DATE_FORMAT(p.birthday,'%Y-%m-%d'),COALESCE(p.height_cm,0),p.city_code,p.city_name,p.occupation,p.bio,p.profile_score,p.is_vip,p.vip_level,p.last_active_at,
(SELECT COUNT(*) FROM user_follows WHERE user_id=u.id),(SELECT COUNT(*) FROM user_follows WHERE target_user_id=u.id),(SELECT COUNT(*) FROM posts WHERE user_id=u.id AND deleted_at IS NULL),(SELECT COUNT(*) FROM post_likes pl JOIN posts po ON po.id=pl.post_id WHERE po.user_id=u.id)
FROM users u JOIN user_profiles p ON p.user_id=u.id WHERE u.id=? AND u.deleted_at IS NULL`, id).Scan(
&publicID, &phoneCipher, &status, &risk, &created, &isTest, &testBatch, &nickname, &avatar, &cover, &gender, &birthday, &height, &cityCode, &city, &occupation, &bio, &profileScore, &vip, &vipLevel, &lastActive, &followingCount, &followerCount, &postCount, &likeCount)
if err != nil {
fail(w, http.StatusNotFound, 30001, "用户不存在")
return
}
phone, decryptErr := a.decryptPhone(phoneCipher)
if decryptErr != nil {
fail(w, http.StatusInternalServerError, 50001, "用户手机号解密失败")
return
}
verification := map[string]any{"status": "UNVERIFIED", "type": "real_name", "realName": "", "documentMask": "", "remark": "", "evidence": []string{}}
verifications := []map[string]any{}
verificationRows, _ := a.db.QueryContext(r.Context(), `SELECT verification_type,status,real_name,document_mask,remark,COALESCE(evidence_json,'[]'),submitted_at,reviewed_at FROM user_verifications WHERE user_id=? ORDER BY verification_type='real_name' DESC,verification_type`, id)
if verificationRows != nil {
defer verificationRows.Close()
for verificationRows.Next() {
var verificationType, verificationStatus, realName, documentMask, verificationRemark, evidenceJSON string
var submittedAt, reviewedAt sql.NullTime
if verificationRows.Scan(&verificationType, &verificationStatus, &realName, &documentMask, &verificationRemark, &evidenceJSON, &submittedAt, &reviewedAt) != nil {
continue
}
evidence := []string{}
_ = json.Unmarshal([]byte(evidenceJSON), &evidence)
item := map[string]any{"type": verificationType, "status": verificationStatus, "realName": realName, "documentMask": documentMask, "remark": verificationRemark, "evidence": evidence, "submittedAt": nullableTime(submittedAt), "reviewedAt": nullableTime(reviewedAt)}
verifications = append(verifications, item)
if len(verifications) == 1 {
verification = item
}
}
}
membership := map[string]any{"active": vip == 1, "level": vipLevel, "name": "普通用户"}
if vip == 1 {
membership["name"] = "历史会员资料"
}
var subscriptionID, planID int64
var planName string
var level int
var startedAt, expiresAt time.Time
if a.db.QueryRowContext(r.Context(), `SELECT s.id,s.plan_id,p.name,p.level,s.started_at,s.expires_at FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3) ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, id).Scan(&subscriptionID, &planID, &planName, &level, &startedAt, &expiresAt) == nil {
membership = map[string]any{"active": true, "subscriptionId": subscriptionID, "planId": planID, "name": planName, "level": level, "startedAt": startedAt, "expiresAt": expiresAt}
}
devices := []map[string]any{}
rows, _ := a.db.QueryContext(r.Context(), `SELECT device_id,platform,device_model,os_version,app_version,last_ip,last_active_at,status FROM user_devices WHERE user_id=? ORDER BY COALESCE(last_active_at,created_at) DESC LIMIT 20`, id)
if rows != nil {
defer rows.Close()
for rows.Next() {
var deviceID, platform, model, osVersion, appVersion, ip string
var active any
var deviceStatus int
_ = rows.Scan(&deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &deviceStatus)
devices = append(devices, map[string]any{"deviceId": deviceID, "platform": platform, "model": model, "osVersion": osVersion, "appVersion": appVersion, "ip": ip, "lastActiveAt": active, "status": deviceStatus})
}
}
var activeSessions, orderCount, paidCent int64
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_sessions WHERE user_id=? AND revoked_at IS NULL AND expires_at>NOW(3)`, id).Scan(&activeSessions)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*),COALESCE(SUM(IF(status='PAID',amount_cent,0)),0) FROM orders WHERE user_id=? AND deleted_at IS NULL`, id).Scan(&orderCount, &paidCent)
reply(w, map[string]any{
"id": id, "publicId": publicID, "isTest": isTest, "testBatch": testBatch, "phone": phone, "status": status, "riskLevel": risk, "createdAt": created,
"profile": map[string]any{"id": id, "publicId": publicID, "nickname": nickname, "avatar": avatar, "cover": cover, "gender": gender, "birthday": nullableString(birthday), "height": height, "cityCode": cityCode, "city": city, "occupation": occupation, "bio": bio, "profileScore": profileScore, "vip": vip == 1, "vipLevel": vipLevel, "lastActiveAt": nullableTime(lastActive), "followingCount": followingCount, "followerCount": followerCount, "postCount": postCount, "likeCount": likeCount},
"verification": verification, "verifications": verifications, "membership": membership, "sanctions": a.sanctionList(r.Context(), id), "devices": devices,
"security": map[string]any{"activeSessions": activeSessions}, "orderSummary": map[string]any{"count": orderCount, "paidCent": paidCent},
})
}
func nullableTime(value sql.NullTime) any {
if value.Valid {
return value.Time
}
return nil
}
func (a *App) adminUpdateUserProfile(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "用户编号无效")
return
}
var req struct {
Phone string `json:"phone"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
Cover string `json:"cover"`
Gender int `json:"gender"`
Birthday string `json:"birthday"`
Height int `json:"height"`
CityCode string `json:"cityCode"`
City string `json:"city"`
Occupation string `json:"occupation"`
Bio string `json:"bio"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Nickname) == "" || len([]rune(req.Nickname)) > 50 || req.Gender < 0 || req.Gender > 2 || req.Height < 0 || req.Height > 260 {
fail(w, 400, 20001, "用户资料格式不正确")
return
}
if req.Birthday != "" {
if _, err = time.Parse("2006-01-02", req.Birthday); err != nil {
fail(w, 400, 20001, "生日格式应为 YYYY-MM-DD")
return
}
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "保存失败")
return
}
defer func() { _ = tx.Rollback() }()
if strings.TrimSpace(req.Phone) != "" {
if !validPhone(req.Phone) {
fail(w, 400, 20001, "手机号格式不正确")
return
}
phoneCipher, encryptErr := a.encryptPhone(req.Phone)
if encryptErr != nil {
fail(w, 500, 50001, "加密手机号失败")
return
}
if _, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), phoneCipher, id); err != nil {
fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用")
return
}
}
_, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET nickname=?,avatar_url=?,cover_url=?,gender=?,birthday=NULLIF(?,''),height_cm=NULLIF(?,0),city_code=?,city_name=?,occupation=?,bio=?,profile_score=GREATEST(profile_score,80) WHERE user_id=?`, strings.TrimSpace(req.Nickname), strings.TrimSpace(req.Avatar), strings.TrimSpace(req.Cover), req.Gender, req.Birthday, req.Height, strings.TrimSpace(req.CityCode), strings.TrimSpace(req.City), strings.TrimSpace(req.Occupation), strings.TrimSpace(req.Bio), id)
if err != nil || tx.Commit() != nil {
fail(w, 500, 50001, "保存失败")
return
}
a.audit(r, "update_profile", "user", id, map[string]any{"nickname": req.Nickname, "phoneChanged": req.Phone != "", "city": req.City})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminUpdateVerification(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "用户编号无效")
return
}
var req struct {
Type string `json:"type"`
Status string `json:"status"`
RealName string `json:"realName"`
DocumentMask string `json:"documentMask"`
Remark string `json:"remark"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "认证资料格式错误")
return
}
req.Status = strings.ToUpper(strings.TrimSpace(req.Status))
if req.Type == "" {
req.Type = "real_name"
}
if req.Status != "UNVERIFIED" && req.Status != "PENDING" && req.Status != "VERIFIED" && req.Status != "REJECTED" {
fail(w, 400, 20001, "认证状态无效")
return
}
if req.Status == "REJECTED" && strings.TrimSpace(req.Remark) == "" {
fail(w, 400, 20001, "驳回认证时必须填写原因")
return
}
_, err = a.db.ExecContext(r.Context(), `INSERT INTO user_verifications(user_id,verification_type,status,real_name,document_mask,evidence_json,remark,reviewer_admin_id,submitted_at,reviewed_at) VALUES(?,?,?,?,?,'[]',?,?,IF(?='PENDING',NOW(3),NULL),IF(? IN ('VERIFIED','REJECTED'),NOW(3),NULL)) ON DUPLICATE KEY UPDATE verification_type=VALUES(verification_type),status=VALUES(status),real_name=VALUES(real_name),document_mask=VALUES(document_mask),remark=VALUES(remark),reviewer_admin_id=VALUES(reviewer_admin_id),submitted_at=IF(VALUES(status)='PENDING',COALESCE(submitted_at,NOW(3)),submitted_at),reviewed_at=IF(VALUES(status) IN ('VERIFIED','REJECTED'),NOW(3),NULL)`, id, req.Type, req.Status, strings.TrimSpace(req.RealName), strings.TrimSpace(req.DocumentMask), strings.TrimSpace(req.Remark), current(r).ID, req.Status, req.Status)
if err != nil {
fail(w, 500, 50001, "保存认证结果失败")
return
}
if req.Status == "VERIFIED" || req.Status == "REJECTED" {
title := "认证审核结果"
content := "认证已通过"
if req.Status == "REJECTED" {
content = "认证未通过:" + strings.TrimSpace(req.Remark)
}
a.notifyUser(r.Context(), id, "system", title, content, "verification", id)
}
a.audit(r, "verify", "user", id, map[string]any{"status": req.Status, "type": req.Type, "remark": req.Remark})
reply(w, map[string]bool{"success": true})
}
func parseAdminExpiry(value string, fallbackDays int) (time.Time, error) {
if strings.TrimSpace(value) == "" {
return time.Now().AddDate(0, 0, fallbackDays), nil
}
if parsed, err := time.Parse(time.RFC3339, value); err == nil {
return parsed, nil
}
parsed, err := time.ParseInLocation("2006-01-02", value, time.Local)
if err != nil {
return time.Time{}, err
}
return parsed.Add(23*time.Hour + 59*time.Minute + 59*time.Second), nil
}
func (a *App) adminUpdateMembership(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "用户编号无效")
return
}
var req struct {
Operation string `json:"operation"`
PlanID int64 `json:"planId"`
ExpiresAt string `json:"expiresAt"`
Reason string `json:"reason"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "会员设置格式错误")
return
}
if req.Operation == "" {
req.Operation = "grant"
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "会员设置失败")
return
}
defer func() { _ = tx.Rollback() }()
if req.Operation == "revoke" {
_, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id)
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, id)
}
} else {
var durationDays, level int
if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, req.PlanID).Scan(&durationDays, &level); err != nil {
fail(w, 400, 20001, "会员套餐不存在")
return
}
expiresAt, parseErr := parseAdminExpiry(req.ExpiresAt, durationDays)
if parseErr != nil || !expiresAt.After(time.Now()) {
fail(w, 400, 20001, "会员到期时间无效")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND status=1`, id)
if err == nil {
_, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,'admin',1,NOW(3),?)`, id, req.PlanID, expiresAt)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level, id)
}
}
if err != nil || tx.Commit() != nil {
fail(w, 500, 50001, "会员设置失败")
return
}
a.audit(r, "membership_"+req.Operation, "user", id, map[string]any{"planId": req.PlanID, "expiresAt": req.ExpiresAt, "reason": req.Reason})
reply(w, map[string]bool{"success": true})
}
func (a *App) forceLogoutUser(ctx context.Context, userID, adminID int64, passwordReset bool) error {
if _, err := a.db.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID); err != nil {
return err
}
if passwordReset {
_, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at,last_operator_admin_id) VALUES(?,1,NOW(3),NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),password_reset_at=VALUES(password_reset_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID)
if err == nil {
a.hub.disconnect(userID)
}
return err
}
_, err := a.db.ExecContext(ctx, `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,last_operator_admin_id) VALUES(?,1,NOW(3),?) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=VALUES(force_logout_at),last_operator_admin_id=VALUES(last_operator_admin_id)`, userID, adminID)
if err == nil {
a.hub.disconnect(userID)
}
return err
}
func (a *App) adminResetUserPassword(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
var req struct {
NewPassword string `json:"newPassword"`
}
if err != nil || decode(r, &req) != nil || !validUserPassword(req.NewPassword) {
fail(w, 400, 20001, "请填写有效的用户编号和新密码")
return
}
hash, err := hashPassword(req.NewPassword)
if err != nil {
fail(w, 500, 50001, "密码加密失败")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=? AND deleted_at IS NULL`, hash, id)
if err != nil {
fail(w, 500, 50001, "重置密码失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 || a.forceLogoutUser(r.Context(), id, current(r).ID, true) != nil {
fail(w, 500, 50001, "重置密码失败")
return
}
a.audit(r, "reset_password", "user", id, map[string]any{"forceLogout": true})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminForceLogout(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil || a.forceLogoutUser(r.Context(), id, current(r).ID, false) != nil {
fail(w, 500, 50001, "强制下线失败")
return
}
a.audit(r, "force_logout", "user", id, nil)
reply(w, map[string]bool{"success": true})
}
func (a *App) sanctionList(ctx context.Context, userID int64) []map[string]any {
items := []map[string]any{}
rows, err := a.db.QueryContext(ctx, `SELECT s.id,s.sanction_type,s.reason,s.starts_at,s.expires_at,s.status,s.operator_admin_id,COALESCE(a.real_name,''),s.revoked_at,s.created_at FROM user_sanctions s LEFT JOIN admin_users a ON a.id=s.operator_admin_id WHERE s.user_id=? ORDER BY s.created_at DESC LIMIT 100`, userID)
if err != nil {
return items
}
defer rows.Close()
for rows.Next() {
var id, operatorID int64
var typ, reason, status, operatorName string
var startsAt, createdAt time.Time
var expiresAt, revokedAt any
_ = rows.Scan(&id, &typ, &reason, &startsAt, &expiresAt, &status, &operatorID, &operatorName, &revokedAt, &createdAt)
items = append(items, map[string]any{"id": id, "type": typ, "reason": reason, "startsAt": startsAt, "expiresAt": expiresAt, "status": status, "operatorId": operatorID, "operatorName": operatorName, "revokedAt": revokedAt, "createdAt": createdAt})
}
return items
}
func (a *App) createSanction(ctx context.Context, adminID, userID int64, typ, reason string, expiresAt *time.Time) (int64, error) {
result, err := a.db.ExecContext(ctx, `INSERT INTO user_sanctions(user_id,sanction_type,reason,expires_at,operator_admin_id) VALUES(?,?,?,?,?)`, userID, typ, reason, expiresAt, adminID)
if err != nil {
return 0, err
}
id, _ := result.LastInsertId()
if typ == "FREEZE" || typ == "BAN" {
status := 2
if typ == "BAN" {
status = 3
}
if _, err = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, status, userID); err == nil {
err = a.forceLogoutUser(ctx, userID, adminID, false)
}
}
if err == nil {
title := map[string]string{"WARNING": "违规警告", "MUTE": "禁言通知", "CONTENT_LIMIT": "内容发布限制", "FREEZE": "账号冻结", "BAN": "账号封禁"}[typ]
a.notifyUser(ctx, userID, "system", title, reason, "sanction", nil)
}
return id, err
}
func (a *App) adminUserSanctions(w http.ResponseWriter, r *http.Request) {
userID, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "用户编号无效")
return
}
if r.Method == http.MethodGet {
reply(w, map[string]any{"items": a.sanctionList(r.Context(), userID)})
return
}
var req struct {
Type string `json:"type"`
Reason string `json:"reason"`
ExpiresAt string `json:"expiresAt"`
}
if decode(r, &req) != nil {
fail(w, 400, 20001, "处罚信息格式错误")
return
}
req.Type = strings.ToUpper(strings.TrimSpace(req.Type))
allowed := map[string]bool{"WARNING": true, "FREEZE": true, "BAN": true, "MUTE": true, "CONTENT_LIMIT": true}
if !allowed[req.Type] || strings.TrimSpace(req.Reason) == "" {
fail(w, 400, 20001, "请选择处罚类型并填写原因")
return
}
var expiry *time.Time
if req.ExpiresAt != "" {
parsed, parseErr := parseAdminExpiry(req.ExpiresAt, 0)
if parseErr != nil || !parsed.After(time.Now()) {
fail(w, 400, 20001, "处罚到期时间无效")
return
}
expiry = &parsed
}
sanctionID, err := a.createSanction(r.Context(), current(r).ID, userID, req.Type, strings.TrimSpace(req.Reason), expiry)
if err != nil {
fail(w, 500, 50001, "执行处罚失败")
return
}
a.audit(r, "sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": req.Type, "reason": req.Reason, "expiresAt": req.ExpiresAt})
reply(w, map[string]any{"id": sanctionID})
}
func (a *App) adminRevokeSanction(w http.ResponseWriter, r *http.Request) {
sanctionID, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "处罚编号无效")
return
}
var userID int64
var typ string
if err = a.db.QueryRowContext(r.Context(), `SELECT user_id,sanction_type FROM user_sanctions WHERE id=? AND status='ACTIVE'`, sanctionID).Scan(&userID, &typ); err != nil {
fail(w, 404, 30001, "有效处罚不存在")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE user_sanctions SET status='REVOKED',revoked_by=?,revoked_at=NOW(3) WHERE id=? AND status='ACTIVE'`, current(r).ID, sanctionID)
affected, _ := result.RowsAffected()
if err != nil || affected == 0 {
fail(w, 500, 50001, "撤销处罚失败")
return
}
if typ == "FREEZE" || typ == "BAN" {
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))`, userID).Scan(&bans, &freezes)
status := 1
if bans > 0 {
status = 3
} else if freezes > 0 {
status = 2
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE users SET status=? WHERE id=?`, status, userID)
}
a.audit(r, "revoke_sanction", "user", userID, map[string]any{"sanctionId": sanctionID, "type": typ})
reply(w, map[string]bool{"success": true})
}
func (a *App) isSanctionActive(ctx context.Context, userID int64, typ string) bool {
var count int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type=? AND status='ACTIVE' AND (expires_at IS NULL OR expires_at>NOW(3))`, userID, typ).Scan(&count)
return count > 0
}
func (a *App) normalizeUserStatus(ctx context.Context, userID int64, status int) int {
if status != 2 && status != 3 {
return status
}
var total int
_ = a.db.QueryRowContext(ctx, `SELECT COUNT(*) FROM user_sanctions WHERE user_id=? AND sanction_type IN ('FREEZE','BAN')`, userID).Scan(&total)
if total == 0 {
return status
}
_, _ = a.db.ExecContext(ctx, `UPDATE user_sanctions SET status='EXPIRED' WHERE user_id=? AND sanction_type IN ('FREEZE','BAN') AND status='ACTIVE' AND expires_at<=NOW(3)`, userID)
var bans, freezes int
_ = a.db.QueryRowContext(ctx, `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))`, userID).Scan(&bans, &freezes)
resolved := 1
if bans > 0 {
resolved = 3
} else if freezes > 0 {
resolved = 2
}
if resolved != status {
_, _ = a.db.ExecContext(ctx, `UPDATE users SET status=? WHERE id=?`, resolved, userID)
}
return resolved
}
func (a *App) recomputeMembershipTx(ctx context.Context, tx *sql.Tx, userID int64) error {
var level sql.NullInt64
if err := tx.QueryRowContext(ctx, `SELECT MAX(p.level) FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id WHERE s.user_id=? AND s.status=1 AND s.expires_at>NOW(3)`, userID).Scan(&level); err != nil {
return err
}
if !level.Valid {
_, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=0,vip_level=0 WHERE user_id=?`, userID)
return err
}
_, err := tx.ExecContext(ctx, `UPDATE user_profiles SET is_vip=1,vip_level=? WHERE user_id=?`, level.Int64, userID)
return err
}
func (a *App) adminOrderTransition(w http.ResponseWriter, r *http.Request, action string) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "订单编号无效")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, 50001, "订单操作失败")
return
}
defer func() { _ = tx.Rollback() }()
var userID, planID int64
var status string
if err = tx.QueryRowContext(r.Context(), `SELECT user_id,product_id,status FROM orders WHERE id=? AND deleted_at IS NULL FOR UPDATE`, id).Scan(&userID, &planID, &status); err != nil {
fail(w, 404, 30001, "订单不存在")
return
}
switch action {
case "pay":
if status != "CREATED" {
fail(w, 400, 20001, "只有待支付订单可标记为已支付")
return
}
var durationDays, level int
if err = tx.QueryRowContext(r.Context(), `SELECT duration_days,level FROM membership_plans WHERE id=? AND deleted_at IS NULL`, planID).Scan(&durationDays, &level); err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='PAID',paid_at=NOW(3) WHERE id=?`, id)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `INSERT INTO subscriptions(user_id,plan_id,source,status,started_at,expires_at) VALUES(?,?,?,1,NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY))`, userID, planID, fmt.Sprintf("admin_order:%d", id), durationDays)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE user_profiles SET is_vip=1,vip_level=GREATEST(vip_level,?) WHERE user_id=?`, level, userID)
}
case "close":
if status != "CREATED" {
fail(w, 400, 20001, "只有待支付订单可关闭")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='CLOSED' WHERE id=?`, id)
case "refund":
if status != "PAID" && status != "REFUND_REQUESTED" {
fail(w, 400, 20001, "只有已支付或用户已申请退款的订单可退款")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE orders SET status='REFUNDED' WHERE id=?`, id)
if err == nil {
result, updateErr := tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE user_id=? AND plan_id=? AND status=1 AND source IN (?,?)`, userID, planID, fmt.Sprintf("order:%d", id), fmt.Sprintf("admin_order:%d", id))
err = updateErr
affected, _ := result.RowsAffected()
if err == nil && affected == 0 {
_, err = tx.ExecContext(r.Context(), `UPDATE subscriptions SET status=0 WHERE id=(SELECT id FROM (SELECT id FROM subscriptions WHERE user_id=? AND plan_id=? AND status=1 ORDER BY started_at DESC LIMIT 1) latest)`, userID, planID)
}
}
if err == nil {
err = a.recomputeMembershipTx(r.Context(), tx, userID)
}
default:
fail(w, 400, 20001, "订单操作无效")
return
}
if err != nil || tx.Commit() != nil {
fail(w, 500, 50001, "订单操作失败")
return
}
a.audit(r, action, "order", id, map[string]any{"userId": userID, "previousStatus": status})
reply(w, map[string]bool{"success": true})
}
func (a *App) adminMarkOrderPaid(w http.ResponseWriter, r *http.Request) {
if a.configPlain(r.Context(), "payment.mode", "sandbox") == "live" {
fail(w, http.StatusBadRequest, 20001, "生产支付订单只能由已验签的支付回调确认入账")
return
}
a.adminOrderTransition(w, r, "pay")
}
func (a *App) adminCloseOrder(w http.ResponseWriter, r *http.Request) {
a.adminOrderTransition(w, r, "close")
}