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 == "" { fail(w, http.StatusBadRequest, 20001, "请填写当前密码和新密码") return } if !validUserPassword(req.NewPassword) { fail(w, http.StatusBadRequest, 20001, passwordRule) 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}) }