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

753 lines
32 KiB
Go

package app
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
type membershipEntitlements struct {
DailyActiveChatLimit int `json:"dailyActiveChatLimit"`
DailyLikeLimit int `json:"dailyLikeLimit"`
CanViewVisitors bool `json:"canViewVisitors"`
CanInvisibleVisit bool `json:"canInvisibleVisit"`
RecommendationWeight int `json:"recommendationWeight"`
}
func (a *App) validateOwnedImageEvidence(ctx context.Context, userID int64, rawURLs []string) ([]string, error) {
items := make([]string, 0, len(rawURLs))
seen := map[string]bool{}
for _, rawURL := range rawURLs {
mediaURL := strings.TrimSpace(rawURL)
if mediaURL == "" || seen[mediaURL] {
return nil, fmt.Errorf("证据图片地址无效或重复")
}
seen[mediaURL] = true
var exists int
if err := a.db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM media_assets WHERE owner_user_id=? AND public_url=? AND media_type='image' AND status=1 AND moderation_status=1)`, userID, mediaURL).Scan(&exists); err != nil || exists != 1 {
return nil, fmt.Errorf("证据图片必须由当前账号上传")
}
items = append(items, mediaURL)
}
return items, nil
}
func (a *App) resolveMembershipEntitlements(ctx context.Context, userID int64) membershipEntitlements {
result := membershipEntitlements{DailyActiveChatLimit: a.resolveDailyActiveChatLimit(ctx, a.db, userID), DailyLikeLimit: 20}
_ = a.db.QueryRowContext(ctx, `SELECT CAST(config_value AS UNSIGNED) FROM system_configs WHERE config_key='membership.free_daily_like_limit'`).Scan(&result.DailyLikeLimit)
var visitors, invisible int
_ = a.db.QueryRowContext(ctx, `SELECT p.daily_like_limit,p.can_view_visitors,p.can_invisible_visit,p.recommendation_weight
FROM subscriptions s JOIN membership_plans p ON p.id=s.plan_id
WHERE s.user_id=? AND s.status=1 AND s.started_at<=NOW(3) AND s.expires_at>NOW(3) AND p.deleted_at IS NULL
ORDER BY p.level DESC,s.expires_at DESC LIMIT 1`, userID).Scan(&result.DailyLikeLimit, &visitors, &invisible, &result.RecommendationWeight)
result.CanViewVisitors = visitors == 1
result.CanInvisibleVisit = invisible == 1
return result
}
type dailyLikeLimitError struct{ Limit int }
func (e *dailyLikeLimitError) Error() string {
return fmt.Sprintf("今日点赞次数已达上限(%d次)", e.Limit)
}
func (a *App) reserveDailyLike(tx *sql.Tx, r *http.Request, targetType string, targetID int64) error {
userID := current(r).ID
if _, err := tx.ExecContext(r.Context(), `INSERT IGNORE INTO user_daily_like_usage(user_id,usage_date,used_count) VALUES(?,CURRENT_DATE(),0)`, userID); err != nil {
return err
}
var used int
if err := tx.QueryRowContext(r.Context(), `SELECT used_count FROM user_daily_like_usage WHERE user_id=? AND usage_date=CURRENT_DATE() FOR UPDATE`, userID).Scan(&used); err != nil {
return err
}
var counted int
if err := tx.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM user_daily_like_targets WHERE user_id=? AND target_type=? AND target_id=? AND usage_date=CURRENT_DATE())`, userID, targetType, targetID).Scan(&counted); err != nil {
return err
}
if counted == 1 {
return nil
}
entitlements := a.resolveMembershipEntitlements(r.Context(), userID)
if entitlements.DailyLikeLimit > 0 && used >= entitlements.DailyLikeLimit {
return &dailyLikeLimitError{Limit: entitlements.DailyLikeLimit}
}
if _, err := tx.ExecContext(r.Context(), `INSERT INTO user_daily_like_targets(user_id,target_type,target_id,usage_date) VALUES(?,?,?,CURRENT_DATE())`, userID, targetType, targetID); err != nil {
return err
}
_, err := tx.ExecContext(r.Context(), `UPDATE user_daily_like_usage SET used_count=used_count+1 WHERE user_id=? AND usage_date=CURRENT_DATE()`, userID)
return err
}
func pageOptions(r *http.Request) (int, int, int) {
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
pageSize, _ := strconv.Atoi(r.URL.Query().Get("pageSize"))
if page < 1 {
page = 1
}
if pageSize < 1 {
pageSize = 20
}
if pageSize > 50 {
pageSize = 50
}
return page, pageSize, (page - 1) * pageSize
}
func nullableInt64(value sql.NullInt64) any {
if value.Valid {
return value.Int64
}
return nil
}
func (a *App) availableTags(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,name,icon,sort_order FROM tags WHERE status=1 ORDER BY category,sort_order,id`)
if err != nil {
fail(w, 500, 50001, "查询标签失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var category, name, icon string
var order int
if rows.Scan(&id, &category, &name, &icon, &order) == nil {
items = append(items, map[string]any{"id": id, "category": category, "name": name, "icon": icon, "sortOrder": order})
}
}
reply(w, map[string]any{"items": items})
}
func (a *App) myDevices(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT s.id,s.device_id,COALESCE(d.platform,''),COALESCE(d.device_model,''),COALESCE(d.os_version,''),COALESCE(d.app_version,''),COALESCE(d.last_ip,''),s.last_active_at,s.expires_at,s.created_at
FROM user_sessions s LEFT JOIN user_devices d ON d.user_id=s.user_id AND d.device_id=s.device_id
WHERE s.user_id=? AND s.revoked_at IS NULL AND s.expires_at>NOW(3) ORDER BY s.last_active_at DESC`, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询登录设备失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var deviceID, platform, model, osVersion, appVersion, ip string
var active, expires, created time.Time
if rows.Scan(&id, &deviceID, &platform, &model, &osVersion, &appVersion, &ip, &active, &expires, &created) == nil {
items = append(items, map[string]any{"id": id, "deviceId": deviceID, "platform": platform, "deviceModel": model, "osVersion": osVersion, "appVersion": appVersion, "lastIp": ip, "lastActiveAt": active, "expiresAt": expires, "createdAt": created})
}
}
reply(w, map[string]any{"items": items})
}
func (a *App) revokeDevice(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, http.StatusBadRequest, 20001, "设备会话编号无效")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE id=? AND user_id=? AND revoked_at IS NULL`, id, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "移除设备失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, http.StatusNotFound, 30001, "设备会话不存在")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) changeUserPassword(w http.ResponseWriter, r *http.Request) {
var req struct {
CurrentPassword string `json:"currentPassword"`
NewPassword string `json:"newPassword"`
}
if decode(r, &req) != nil || req.CurrentPassword == "" || !validUserPassword(req.NewPassword) {
fail(w, http.StatusBadRequest, 20001, "请输入当前密码和新密码")
return
}
var oldHash string
if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&oldHash) != nil || !checkPassword(oldHash, req.CurrentPassword) {
fail(w, http.StatusUnauthorized, 10001, "当前密码错误")
return
}
newHash, err := hashPassword(req.NewPassword)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "密码加密失败")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
defer func() { _ = tx.Rollback() }()
_, err = tx.ExecContext(r.Context(), `UPDATE users SET password_hash=? WHERE id=?`, newHash, current(r).ID)
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at,password_reset_at) VALUES(?,1,NOW(3),NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3),password_reset_at=NOW(3)`, current(r).ID)
}
if err != nil || tx.Commit() != nil {
fail(w, http.StatusInternalServerError, 50001, "修改密码失败")
return
}
a.hub.disconnect(current(r).ID)
reply(w, map[string]bool{"success": true, "reloginRequired": true})
}
func (a *App) changeUserPhone(w http.ResponseWriter, r *http.Request) {
var req struct {
Phone string `json:"phone"`
Code string `json:"code"`
CurrentPassword string `json:"currentPassword"`
}
if decode(r, &req) != nil || !validPhone(req.Phone) || len(req.Code) != 6 || req.CurrentPassword == "" {
fail(w, http.StatusBadRequest, 20001, "当前密码、手机号或验证码格式错误")
return
}
var passwordHash string
if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=? AND deleted_at IS NULL`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.CurrentPassword) {
fail(w, http.StatusUnauthorized, 10001, "当前密码错误")
return
}
if !a.consumeSMSCode(r, req.Phone, "change_phone", req.Code) {
fail(w, http.StatusBadRequest, 20001, "验证码错误或已过期")
return
}
var exists int
_ = a.db.QueryRowContext(r.Context(), `SELECT EXISTS(SELECT 1 FROM users WHERE phone_hash=? AND id<>? AND deleted_at IS NULL)`, phoneHash(req.Phone), current(r).ID).Scan(&exists)
if exists == 1 {
fail(w, http.StatusConflict, 20001, "手机号已被其他账号使用")
return
}
cipher, err := a.encryptPhone(req.Phone)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "加密手机号失败")
return
}
tx, err := a.db.BeginTx(r.Context(), nil)
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE users SET phone_hash=?,phone_cipher=? WHERE id=?`, phoneHash(req.Phone), cipher, current(r).ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, current(r).ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), `INSERT INTO user_security_controls(user_id,token_version,force_logout_at) VALUES(?,1,NOW(3)) ON DUPLICATE KEY UPDATE token_version=token_version+1,force_logout_at=NOW(3)`, current(r).ID)
}
if err != nil || tx.Commit() != nil {
_ = tx.Rollback()
fail(w, http.StatusInternalServerError, 50001, "更换手机号失败")
return
}
a.hub.disconnect(current(r).ID)
reply(w, map[string]bool{"success": true, "reloginRequired": true})
}
func (a *App) blockedUsers(w http.ResponseWriter, r *http.Request) {
rows, err := a.db.QueryContext(r.Context(), `SELECT blocked_user_id FROM user_blocks WHERE user_id=? ORDER BY created_at DESC`, current(r).ID)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询黑名单失败")
return
}
defer rows.Close()
items := []profileView{}
for rows.Next() {
var id int64
_ = rows.Scan(&id)
if item, loadErr := a.loadProfile(r, id, current(r).ID); loadErr == nil {
item.AvatarThumbnail = avatarThumbnailURL(item.Avatar)
items = append(items, item)
}
}
reply(w, map[string]any{"items": items})
}
type notificationSettingsView struct {
IMEnabled bool `json:"imEnabled"`
InteractionEnabled bool `json:"interactionEnabled"`
SystemEnabled bool `json:"systemEnabled"`
SoundEnabled bool `json:"soundEnabled"`
VibrationEnabled bool `json:"vibrationEnabled"`
QuietStart string `json:"quietStart"`
QuietEnd string `json:"quietEnd"`
}
func (a *App) notificationAllowed(ctx context.Context, userID int64, notificationType string) bool {
var enabled int
column := "interaction_enabled"
if notificationType == "system" {
column = "system_enabled"
} else if notificationType == "im" {
column = "im_enabled"
}
err := a.db.QueryRowContext(ctx, `SELECT `+column+` FROM user_notification_settings WHERE user_id=?`, userID).Scan(&enabled)
return err == sql.ErrNoRows || (err == nil && enabled == 1)
}
func (a *App) notifyUser(ctx context.Context, userID int64, notificationType, title, content, bizType string, bizID any) {
if userID <= 0 || !a.notificationAllowed(ctx, userID, notificationType) {
return
}
_, _ = a.db.ExecContext(ctx, `INSERT INTO notifications(user_id,type,title,content,biz_type,biz_id) VALUES(?,?,?,?,?,?)`, userID, notificationType, title, content, bizType, bizID)
}
func (a *App) notificationSettings(w http.ResponseWriter, r *http.Request) {
_, _ = a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_notification_settings(user_id) VALUES(?)`, current(r).ID)
var item notificationSettingsView
if err := a.db.QueryRowContext(r.Context(), `SELECT im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end FROM user_notification_settings WHERE user_id=?`, current(r).ID).Scan(&item.IMEnabled, &item.InteractionEnabled, &item.SystemEnabled, &item.SoundEnabled, &item.VibrationEnabled, &item.QuietStart, &item.QuietEnd); err != nil {
fail(w, http.StatusInternalServerError, 50001, "查询通知设置失败")
return
}
reply(w, item)
}
func validClock(value string) bool {
if value == "" {
return true
}
if len(value) != 5 || value[2] != ':' {
return false
}
hour, hErr := strconv.Atoi(value[:2])
minute, mErr := strconv.Atoi(value[3:])
return hErr == nil && mErr == nil && hour >= 0 && hour < 24 && minute >= 0 && minute < 60
}
func (a *App) updateNotificationSettings(w http.ResponseWriter, r *http.Request) {
var req notificationSettingsView
if decode(r, &req) != nil || !validClock(req.QuietStart) || !validClock(req.QuietEnd) {
fail(w, http.StatusBadRequest, 20001, "通知设置格式错误")
return
}
_, err := a.db.ExecContext(r.Context(), `INSERT INTO user_notification_settings(user_id,im_enabled,interaction_enabled,system_enabled,sound_enabled,vibration_enabled,quiet_start,quiet_end) VALUES(?,?,?,?,?,?,?,?) ON DUPLICATE KEY UPDATE im_enabled=VALUES(im_enabled),interaction_enabled=VALUES(interaction_enabled),system_enabled=VALUES(system_enabled),sound_enabled=VALUES(sound_enabled),vibration_enabled=VALUES(vibration_enabled),quiet_start=VALUES(quiet_start),quiet_end=VALUES(quiet_end)`, current(r).ID, req.IMEnabled, req.InteractionEnabled, req.SystemEnabled, req.SoundEnabled, req.VibrationEnabled, req.QuietStart, req.QuietEnd)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存通知设置失败")
return
}
reply(w, req)
}
func (a *App) registerPushToken(w http.ResponseWriter, r *http.Request) {
var req struct {
DeviceID string `json:"deviceId"`
Provider string `json:"provider"`
PushToken string `json:"pushToken"`
Platform string `json:"platform"`
AppVersion string `json:"appVersion"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.DeviceID) == "" || strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.PushToken) == "" || len(req.PushToken) > 500 {
fail(w, http.StatusBadRequest, 20001, "推送设备信息不完整")
return
}
_, err := a.db.ExecContext(r.Context(), `INSERT INTO user_push_tokens(user_id,device_id,provider,push_token,platform,app_version,status,last_active_at) VALUES(?,?,?,?,?,?,1,NOW(3)) ON DUPLICATE KEY UPDATE push_token=VALUES(push_token),platform=VALUES(platform),app_version=VALUES(app_version),status=1,last_active_at=NOW(3)`, current(r).ID, req.DeviceID, req.Provider, req.PushToken, req.Platform, req.AppVersion)
if err != nil {
fail(w, http.StatusInternalServerError, 50001, "保存推送设备失败")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) deletePushToken(w http.ResponseWriter, r *http.Request) {
deviceID := strings.TrimSpace(r.URL.Query().Get("deviceId"))
if deviceID == "" {
fail(w, http.StatusBadRequest, 20001, "设备标识不能为空")
return
}
_, _ = a.db.ExecContext(r.Context(), `UPDATE user_push_tokens SET status=0 WHERE user_id=? AND device_id=?`, current(r).ID, deviceID)
reply(w, map[string]bool{"success": true})
}
func (a *App) feedback(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
rows, err := a.db.QueryContext(r.Context(), `SELECT id,category,content,contact,evidence_json,status,reply_content,created_at,updated_at FROM user_feedback WHERE user_id=? ORDER BY created_at DESC LIMIT 100`, current(r).ID)
if err != nil {
fail(w, 500, 50001, "查询反馈失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int64
var category, content, contact, evidenceJSON, status, reply string
var created, updated time.Time
if rows.Scan(&id, &category, &content, &contact, &evidenceJSON, &status, &reply, &created, &updated) == nil {
var evidence []string
_ = json.Unmarshal([]byte(evidenceJSON), &evidence)
items = append(items, map[string]any{"id": id, "category": category, "content": content, "contact": contact, "evidence": evidence, "status": status, "reply": reply, "createdAt": created, "updatedAt": updated})
}
}
reply(w, map[string]any{"items": items})
return
}
var req struct {
Category string `json:"category"`
Content string `json:"content"`
Contact string `json:"contact"`
Evidence []string `json:"evidence"`
}
req.Category = strings.ToLower(strings.TrimSpace(req.Category))
if decode(r, &req) != nil {
fail(w, 400, 20001, "反馈格式错误")
return
}
req.Category = strings.ToLower(strings.TrimSpace(req.Category))
req.Content = strings.TrimSpace(req.Content)
allowed := map[string]bool{"bug": true, "suggestion": true, "complaint": true, "other": true}
if !allowed[req.Category] || len([]rune(req.Content)) < 5 || len([]rune(req.Content)) > 2000 || len(req.Evidence) > 6 {
fail(w, http.StatusBadRequest, 20001, "请选择反馈类型并填写 5-2000 字内容")
return
}
evidence, evidenceErr := a.validateOwnedImageEvidence(r.Context(), current(r).ID, req.Evidence)
if evidenceErr != nil {
fail(w, http.StatusBadRequest, 20001, evidenceErr.Error())
return
}
evidenceJSON, _ := json.Marshal(evidence)
result, err := a.db.ExecContext(r.Context(), `INSERT INTO user_feedback(user_id,category,content,contact,evidence_json) VALUES(?,?,?,?,?)`, current(r).ID, req.Category, req.Content, strings.TrimSpace(req.Contact), evidenceJSON)
if err != nil {
fail(w, 500, 50001, "提交反馈失败")
return
}
id, _ := result.LastInsertId()
reply(w, map[string]any{"id": id, "status": "PENDING"})
}
func (a *App) accountClosure(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodGet {
var status, reason string
var requested, executeAfter time.Time
var cancelled, completed sql.NullTime
err := a.db.QueryRowContext(r.Context(), `SELECT status,reason,requested_at,execute_after,cancelled_at,completed_at FROM user_account_closures WHERE user_id=?`, current(r).ID).Scan(&status, &reason, &requested, &executeAfter, &cancelled, &completed)
if err == sql.ErrNoRows {
reply(w, map[string]any{"status": "NONE"})
return
}
if err != nil {
fail(w, 500, 50001, "查询注销状态失败")
return
}
reply(w, map[string]any{"status": status, "reason": reason, "requestedAt": requested, "executeAfter": executeAfter, "cancelledAt": nullableTime(cancelled), "completedAt": nullableTime(completed)})
return
}
if r.Method == http.MethodDelete {
result, err := a.db.ExecContext(r.Context(), `UPDATE user_account_closures SET status='CANCELLED',cancelled_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after>NOW(3)`, current(r).ID)
if err != nil {
fail(w, 500, 50001, "取消注销失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 409, 20001, "当前没有可取消的注销申请")
return
}
reply(w, map[string]bool{"success": true})
return
}
var req struct {
Password string `json:"password"`
Reason string `json:"reason"`
}
if decode(r, &req) != nil || req.Password == "" {
fail(w, 400, 20001, "请输入当前密码确认注销")
return
}
var passwordHash string
if a.db.QueryRowContext(r.Context(), `SELECT password_hash FROM users WHERE id=?`, current(r).ID).Scan(&passwordHash) != nil || !checkPassword(passwordHash, req.Password) {
fail(w, 401, 10001, "当前密码错误")
return
}
days, _ := strconv.Atoi(a.configPlain(r.Context(), "account.cancellation_cooling_days", "7"))
if days < 1 {
days = 7
}
_, err := a.db.ExecContext(r.Context(), `INSERT INTO user_account_closures(user_id,reason,status,requested_at,execute_after,cancelled_at,completed_at) VALUES(?,?,'PENDING',NOW(3),DATE_ADD(NOW(3),INTERVAL ? DAY),NULL,NULL) ON DUPLICATE KEY UPDATE reason=VALUES(reason),status='PENDING',requested_at=NOW(3),execute_after=VALUES(execute_after),cancelled_at=NULL,completed_at=NULL`, current(r).ID, strings.TrimSpace(req.Reason), days)
if err != nil {
fail(w, 500, 50001, "提交注销申请失败")
return
}
reply(w, map[string]any{"status": "PENDING", "coolingDays": days})
}
func (a *App) executeDueAccountClosureContext(ctx context.Context, userID int64) bool {
tx, err := a.db.BeginTx(ctx, nil)
if err != nil {
return false
}
defer func() { _ = tx.Rollback() }()
result, err := tx.ExecContext(ctx, `UPDATE user_account_closures SET status='COMPLETED',completed_at=NOW(3) WHERE user_id=? AND status='PENDING' AND execute_after<=NOW(3)`, userID)
if err != nil {
return false
}
affected, _ := result.RowsAffected()
if affected == 0 {
return false
}
_, err = tx.ExecContext(ctx, `UPDATE users SET status=0,deleted_at=NOW(3),phone_hash=NULL,phone_cipher=NULL,password_hash='' WHERE id=?`, userID)
if err == nil {
_, err = tx.ExecContext(ctx, `UPDATE user_sessions SET revoked_at=NOW(3) WHERE user_id=? AND revoked_at IS NULL`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `UPDATE user_profiles SET nickname='已注销用户',avatar_url='',cover_url='',gender=0,birthday=NULL,height_cm=NULL,city_code='',city_name='',occupation='',education=0,relationship_status=0,bio='',is_vip=0,vip_level=0,last_active_at=NULL WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `UPDATE posts SET status=0,deleted_at=COALESCE(deleted_at,NOW(3)) WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `DELETE FROM user_location_states WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `DELETE FROM user_push_tokens WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `DELETE FROM user_oauth_identities WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `DELETE FROM user_verifications WHERE user_id=?`, userID)
}
if err == nil {
_, err = tx.ExecContext(ctx, `UPDATE user_feedback SET contact='',evidence_json=JSON_ARRAY() WHERE user_id=?`, userID)
}
if err != nil || tx.Commit() != nil {
return false
}
a.hub.disconnect(userID)
return true
}
func (a *App) executeDueAccountClosure(r *http.Request, userID int64) bool {
return a.executeDueAccountClosureContext(r.Context(), userID)
}
func (a *App) processDueAccountClosures(ctx context.Context) {
rows, err := a.db.QueryContext(ctx, `SELECT user_id FROM user_account_closures WHERE status='PENDING' AND execute_after<=NOW(3) ORDER BY execute_after LIMIT 100`)
if err != nil {
return
}
ids := []int64{}
for rows.Next() {
var id int64
if rows.Scan(&id) == nil {
ids = append(ids, id)
}
}
_ = rows.Close()
for _, id := range ids {
a.executeDueAccountClosureContext(ctx, id)
}
}
func (a *App) exportMyData(w http.ResponseWriter, r *http.Request) {
profile, err := a.loadProfile(r, current(r).ID, current(r).ID)
if err != nil {
fail(w, 404, 30001, "用户不存在")
return
}
var privacy privacyView
var p [8]int
_ = a.db.QueryRowContext(r.Context(), `SELECT nearby_visible,distance_visible,online_visible,last_active_visible,allow_stranger_message,allow_profile_visit_record,allow_search,invisible_visit FROM user_privacy_settings WHERE user_id=?`, current(r).ID).Scan(&p[0], &p[1], &p[2], &p[3], &p[4], &p[5], &p[6], &p[7])
privacy = privacyView{NearbyVisible: p[0] == 1, DistanceVisible: p[1] == 1, OnlineVisible: p[2] == 1, LastActiveVisible: p[3] == 1, AllowStrangerMessage: p[4] == 1, AllowProfileVisitRecord: p[5] == 1, AllowSearch: p[6] == 1, InvisibleVisit: p[7] == 1}
var posts, messages, orders int
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM posts WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&posts)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM im_messages WHERE sender_id=?`, current(r).ID).Scan(&messages)
_ = a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM orders WHERE user_id=? AND deleted_at IS NULL`, current(r).ID).Scan(&orders)
reply(w, map[string]any{"exportedAt": time.Now(), "profile": profile, "privacy": privacy, "statistics": map[string]int{"posts": posts, "messages": messages, "orders": orders}, "notice": "聊天内容、订单明细和认证材料涉及敏感信息,请通过客服完成加密归档导出。"})
}
func (a *App) recordConsent(w http.ResponseWriter, r *http.Request) {
var req struct {
Type string `json:"type"`
Version string `json:"version"`
DeviceID string `json:"deviceId"`
}
if decode(r, &req) != nil || (req.Type != "user_agreement" && req.Type != "privacy_policy") || strings.TrimSpace(req.Version) == "" {
fail(w, 400, 20001, "协议确认信息无效")
return
}
_, err := a.db.ExecContext(r.Context(), `INSERT IGNORE INTO user_consents(user_id,agreement_type,agreement_version,ip,device_id) VALUES(?,?,?,?,?)`, current(r).ID, req.Type, strings.TrimSpace(req.Version), clientIP(r), strings.TrimSpace(req.DeviceID))
if err != nil {
fail(w, 500, 50001, "保存协议确认失败")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) updatePost(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "动态编号无效")
return
}
var req struct {
Content string `json:"content"`
Visibility int `json:"visibility"`
Location string `json:"location"`
}
if decode(r, &req) != nil || strings.TrimSpace(req.Content) == "" || len([]rune(req.Content)) > 2000 {
fail(w, 400, 20001, "动态内容需为 1-2000 字")
return
}
if req.Visibility != 2 {
req.Visibility = 1
}
result, err := a.db.ExecContext(r.Context(), `UPDATE posts SET content=?,visibility=?,location_text=?,moderation_status=1 WHERE id=? AND user_id=? AND status=1`, strings.TrimSpace(req.Content), req.Visibility, strings.TrimSpace(req.Location), id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "修改动态失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 404, 30001, "动态不存在或无权修改")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) deleteOwnPost(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 user_id=? AND status=1`, id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "删除动态失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 404, 30001, "动态不存在或无权删除")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) deleteOwnComment(w http.ResponseWriter, r *http.Request) {
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 postID int64
if err = tx.QueryRowContext(r.Context(), `SELECT c.post_id FROM post_comments c JOIN posts p ON p.id=c.post_id WHERE c.id=? AND (c.user_id=? OR p.user_id=?) AND c.status=1 FOR UPDATE`, id, current(r).ID, current(r).ID).Scan(&postID); err != nil {
fail(w, 404, 30001, "评论不存在或无权删除")
return
}
_, err = tx.ExecContext(r.Context(), `UPDATE post_comments SET status=0,deleted_at=NOW(3) WHERE id=?`, id)
if err == nil {
_, err = tx.ExecContext(r.Context(), `UPDATE posts SET comment_count=GREATEST(comment_count-1,0) WHERE id=?`, postID)
}
if err != nil || tx.Commit() != nil {
fail(w, 500, 50001, "删除评论失败")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) readNotification(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 notifications SET read_at=COALESCE(read_at,NOW(3)) WHERE id=? AND user_id=?`, id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "更新通知失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 404, 30001, "通知不存在")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) myReports(w http.ResponseWriter, r *http.Request) {
page, pageSize, offset := pageOptions(r)
var total int
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM reports WHERE reporter_user_id=?`, current(r).ID).Scan(&total); err != nil {
fail(w, 500, 50001, "查询举报记录失败")
return
}
rows, err := a.db.QueryContext(r.Context(), `SELECT id,target_type,target_id,reason_code,description,evidence_json,status,action_type,handle_remark,created_at,handled_at FROM reports WHERE reporter_user_id=? ORDER BY created_at DESC LIMIT ? OFFSET ?`, current(r).ID, pageSize, offset)
if err != nil {
fail(w, 500, 50001, "查询举报记录失败")
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id, targetID int64
var typ, reason, description, evidenceJSON, status, actionType, handleRemark string
var created time.Time
var handled sql.NullTime
if rows.Scan(&id, &typ, &targetID, &reason, &description, &evidenceJSON, &status, &actionType, &handleRemark, &created, &handled) == nil {
var evidence []string
_ = json.Unmarshal([]byte(evidenceJSON), &evidence)
items = append(items, map[string]any{"id": id, "targetType": typ, "targetId": targetID, "reason": reason, "description": description, "evidence": evidence, "status": status, "actionType": actionType, "handleRemark": handleRemark, "createdAt": created, "handledAt": nullableTime(handled)})
}
}
reply(w, map[string]any{"items": items, "page": page, "pageSize": pageSize, "total": total, "hasMore": offset+len(items) < total})
}
func (a *App) closeOwnOrder(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 orders SET status='CLOSED' WHERE id=? AND user_id=? AND status='CREATED' AND deleted_at IS NULL`, id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "关闭订单失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 409, 20001, "当前订单状态无法关闭")
return
}
reply(w, map[string]bool{"success": true})
}
func (a *App) requestOrderRefund(w http.ResponseWriter, r *http.Request) {
id, err := pathID(r)
if err != nil {
fail(w, 400, 20001, "订单编号无效")
return
}
var req struct {
Reason string `json:"reason"`
}
if decode(r, &req) != nil || len([]rune(strings.TrimSpace(req.Reason))) < 2 {
fail(w, 400, 20001, "请填写退款原因")
return
}
result, err := a.db.ExecContext(r.Context(), `UPDATE orders SET status='REFUND_REQUESTED',refund_reason=?,refund_requested_at=NOW(3) WHERE id=? AND user_id=? AND status='PAID' AND deleted_at IS NULL`, strings.TrimSpace(req.Reason), id, current(r).ID)
if err != nil {
fail(w, 500, 50001, "提交退款申请失败")
return
}
affected, _ := result.RowsAffected()
if affected == 0 {
fail(w, 409, 20001, "当前订单状态无法申请退款")
return
}
reply(w, map[string]bool{"success": true})
}