一、密码规则:至少 8 位且不能全是数字。短信验证可以在后台关掉,关掉之后 密码就是账号质量的唯一门槛,而此前 validUserPassword 只检查非空——"1" 也是合法密码。规则只在「设置密码」时校验(注册、找回、改密、后台建号与 后台重置),登录不再校验,已有账号照常使用。上下限都按字符数计算,否则 43 个汉字的密码会因为字节数超限被拒。各处错误提示改为直接说明规则, 而不是笼统的一句「请填写有效的密码」。 二、离线推送:此前客户端一直在上报 push token,服务端从未下发过任何东西, App 退到后台或被杀掉时新消息完全没有提醒(MESSAGE_PUSH 只是 WebSocket 帧名)。补上服务端下发: - 只发给「此刻不在线 + 未对该会话免打扰 + 未关闭消息通知」的接收者, 在线的人已经从实时通道拿到了。 - 鉴权 token 按 provider 的过期时间缓存,个推的 auth 接口限流很紧。 - 失效的 cid(10001/10002)就地停用,不再每条消息重试一次。 - 整个过程在独立 goroutine 与独立 context 上进行,推送服务再慢也不会 拖慢或拖垮一条已经发出的消息。 - 是否显示正文由 push.show_preview 控制,关闭后锁屏上不出现消息内容。 凭据在管理端「离线推送」页填写(迁移 035 先建出配置行——集成配置保存 走的是 UPDATE,行不存在会静默保存不上)。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
577 lines
25 KiB
Go
577 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 {
|
|
fail(w, 400, 20001, "请填写有效的用户编号和新密码")
|
|
return
|
|
}
|
|
if !validUserPassword(req.NewPassword) {
|
|
fail(w, 400, 20001, passwordRule)
|
|
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")
|
|
}
|