refactor: 后端标识统一为 FruitShorts,并落地 05 离线下载
为什么: 1) 产品已定名 FruitShorts,但后端仍是 reelshort 系列标识 —— Java 包 com.reelshort、 模块名与 jar 名 reelshort-*、spring.application.name、配置前缀 reelshort.*, 这些会出现在启动日志、命令行和 IDE 里,与产品名不一致。本次整体替换为 fruitshorts, 数据库连接名上一轮已改名,保持不变。 2) 第一阶段下一批第 1 步(05 离线下载)要求把下载授权(设备绑定/有效期/吊销)、 下载任务与断点续传、配额与过期清理、仅 Wi-Fi 规则做成完整接口,本提交一并落地。 3) 顺带修复:schema-phase1.sql 里 playback_ticket / order_event 仍按普通表定义唯一索引, 表分区化之后重跑脚本必定报错(分区表的唯一索引必须包含分区键)。改为在本脚本里就 按分区表定义,并验证脚本可重复执行。
This commit is contained in:
1 parent
a4aa749cc6
commit
b3179fc836
251 files changed
+7261
-767
No files matched your search
+36
@@ -0,0 +1,36 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 账号注销申请与冷静期。 */
|
||||
@Data
|
||||
@TableName("app_account_deletion")
|
||||
public class AppAccountDeletion {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** pending/cancelled/completed */
|
||||
private String status;
|
||||
private String reason;
|
||||
private LocalDateTime requestedAt;
|
||||
private LocalDateTime scheduledAt;
|
||||
private LocalDateTime cancelledAt;
|
||||
private LocalDateTime completedAt;
|
||||
private Long operatorId;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** C 端用户。 */
|
||||
@Data
|
||||
@TableName("app_user")
|
||||
public class AppUser {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
/** 对外用户标识,接口只暴露 uid,不暴露自增 ID */
|
||||
private String uid;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String gender;
|
||||
private LocalDate birthday;
|
||||
private String countryCode;
|
||||
private String regionCode;
|
||||
private String language;
|
||||
private String timezone;
|
||||
private String currency;
|
||||
/** normal/limited/banned/deactivated */
|
||||
private String status;
|
||||
private String registerSource;
|
||||
private String registerChannel;
|
||||
private String registerDeviceId;
|
||||
private String registerIp;
|
||||
private LocalDateTime lastLoginAt;
|
||||
private String lastLoginIp;
|
||||
private Integer ageVerified;
|
||||
private String agreementVersion;
|
||||
private LocalDateTime agreementAt;
|
||||
/** 预留:会员等级与到期(第二阶段) */
|
||||
private Integer vipLevel;
|
||||
private LocalDateTime vipExpireAt;
|
||||
/** 预留:风控等级 */
|
||||
private Integer riskLevel;
|
||||
/** 预留:邀请码与推荐人 */
|
||||
private String inviteCode;
|
||||
private Long referrerUserId;
|
||||
/** 下载偏好:1 表示仅 Wi-Fi 下载 */
|
||||
private Integer downloadWifiOnly;
|
||||
/** 下载偏好:允许的最高清晰度,AUTO 表示不限制 */
|
||||
private String downloadMaxQuality;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 封禁/限制与申诉。 */
|
||||
@Data
|
||||
@TableName("app_user_ban_record")
|
||||
public class AppUserBanRecord {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** BAN/LIMIT/MUTE */
|
||||
private String type;
|
||||
private String reasonCode;
|
||||
private String reason;
|
||||
private String refType;
|
||||
private Long refId;
|
||||
private LocalDateTime startAt;
|
||||
private LocalDateTime endAt;
|
||||
private Long operatorId;
|
||||
/** none/pending/approved/rejected */
|
||||
private String appealStatus;
|
||||
private String appealContent;
|
||||
private LocalDateTime appealAt;
|
||||
private Long handleBy;
|
||||
private LocalDateTime handleAt;
|
||||
private String handleNote;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 同意管理记录。 */
|
||||
@Data
|
||||
@TableName("app_user_consent")
|
||||
public class AppUserConsent {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String scene;
|
||||
private String version;
|
||||
private Integer agreed;
|
||||
private String ip;
|
||||
private String deviceId;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 登录凭证:手机号、邮箱、Apple、Google 各一行。 */
|
||||
@Data
|
||||
@TableName("app_user_credential")
|
||||
public class AppUserCredential {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
/** PHONE/EMAIL/APPLE/GOOGLE */
|
||||
private String type;
|
||||
private String identifier;
|
||||
private String federationId;
|
||||
private String appId;
|
||||
private String passwordHash;
|
||||
private Integer verified;
|
||||
private LocalDateTime verifiedAt;
|
||||
private LocalDateTime lastUsedAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 用户设备,用于下载授权与并发播放数限制。 */
|
||||
@Data
|
||||
@TableName("app_user_device")
|
||||
public class AppUserDevice {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String deviceId;
|
||||
private String platform;
|
||||
private String deviceModel;
|
||||
private LocalDateTime firstSeenAt;
|
||||
private LocalDateTime lastSeenAt;
|
||||
private String status;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 验证码记录,同时承担反刷频控。 */
|
||||
@Data
|
||||
@TableName("app_user_otp")
|
||||
public class AppUserOtp {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private String scene;
|
||||
private String targetType;
|
||||
private String target;
|
||||
private String codeHash;
|
||||
private LocalDateTime expireAt;
|
||||
private LocalDateTime usedAt;
|
||||
private String sendIp;
|
||||
private String sendDeviceId;
|
||||
private Integer sendCount;
|
||||
private Integer failCount;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.fruitshorts.user.dal.entity;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldStrategy;
|
||||
import com.baomidou.mybatisplus.annotation.IdType;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableId;
|
||||
import com.baomidou.mybatisplus.annotation.TableLogic;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** C 端会话。refresh token 只存哈希,支持多设备与逐端登出。 */
|
||||
@Data
|
||||
@TableName("app_user_session")
|
||||
public class AppUserSession {
|
||||
|
||||
@TableId(type = IdType.AUTO)
|
||||
private Long id;
|
||||
private Long userId;
|
||||
private String refreshTokenHash;
|
||||
private String deviceId;
|
||||
private String deviceModel;
|
||||
private String osVersion;
|
||||
private String appVersion;
|
||||
private String ip;
|
||||
private String userAgent;
|
||||
private String platform;
|
||||
private LocalDateTime expireAt;
|
||||
private LocalDateTime revokedAt;
|
||||
private LocalDateTime lastActiveAt;
|
||||
private LocalDateTime createdAt;
|
||||
|
||||
@TableField(updateStrategy = FieldStrategy.NEVER)
|
||||
private LocalDateTime updatedAt;
|
||||
|
||||
@TableLogic
|
||||
private Integer deleted;
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppAccountDeletion;
|
||||
|
||||
public interface AppAccountDeletionMapper extends BaseMapper<AppAccountDeletion> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
|
||||
|
||||
public interface AppUserBanRecordMapper extends BaseMapper<AppUserBanRecord> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserConsent;
|
||||
|
||||
public interface AppUserConsentMapper extends BaseMapper<AppUserConsent> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserCredential;
|
||||
|
||||
public interface AppUserCredentialMapper extends BaseMapper<AppUserCredential> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserDevice;
|
||||
|
||||
public interface AppUserDeviceMapper extends BaseMapper<AppUserDevice> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
|
||||
public interface AppUserMapper extends BaseMapper<AppUser> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserOtp;
|
||||
|
||||
public interface AppUserOtpMapper extends BaseMapper<AppUserOtp> {
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
package com.fruitshorts.user.dal.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.fruitshorts.user.dal.entity.AppUserSession;
|
||||
|
||||
public interface AppUserSessionMapper extends BaseMapper<AppUserSession> {
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.fruitshorts.user.enums;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 账号状态。
|
||||
* 封禁与注销必须同时清理会话,不能只改状态位,否则旧 token 仍然可用。
|
||||
*/
|
||||
public final class AppUserStatus {
|
||||
|
||||
public static final String NORMAL = "normal";
|
||||
public static final String LIMITED = "limited";
|
||||
public static final String BANNED = "banned";
|
||||
public static final String DEACTIVATED = "deactivated";
|
||||
|
||||
public static final List<String> ALL = List.of(NORMAL, LIMITED, BANNED, DEACTIVATED);
|
||||
|
||||
/** 这些状态下不允许登录。 */
|
||||
public static boolean isBlocked(String status) {
|
||||
return BANNED.equals(status) || DEACTIVATED.equals(status);
|
||||
}
|
||||
|
||||
private AppUserStatus() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.fruitshorts.user.enums;
|
||||
|
||||
/** 登录凭证类型。 */
|
||||
public enum CredentialType {
|
||||
PHONE,
|
||||
EMAIL,
|
||||
APPLE,
|
||||
GOOGLE;
|
||||
|
||||
public static boolean isThirdParty(String type) {
|
||||
return APPLE.name().equals(type) || GOOGLE.name().equals(type);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.fruitshorts.user.enums;
|
||||
|
||||
/** 注销申请状态。 */
|
||||
public final class DeletionStatus {
|
||||
|
||||
public static final String PENDING = "pending";
|
||||
public static final String CANCELLED = "cancelled";
|
||||
public static final String COMPLETED = "completed";
|
||||
|
||||
private DeletionStatus() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.fruitshorts.user.enums;
|
||||
|
||||
/** 验证码使用场景。 */
|
||||
public enum OtpScene {
|
||||
REGISTER,
|
||||
LOGIN,
|
||||
RESET_PWD,
|
||||
BIND;
|
||||
|
||||
public static boolean isValid(String scene) {
|
||||
for (OtpScene item : values()) {
|
||||
if (item.name().equals(scene)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+343
@@ -0,0 +1,343 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.framework.security.JwtTokenProvider;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.entity.AppUserConsent;
|
||||
import com.fruitshorts.user.dal.entity.AppUserCredential;
|
||||
import com.fruitshorts.user.dal.entity.AppUserDevice;
|
||||
import com.fruitshorts.user.dal.entity.AppUserSession;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserConsentMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserSessionMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.enums.OtpScene;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import com.fruitshorts.user.vo.LoginReqVO;
|
||||
import com.fruitshorts.user.vo.LogoutReqVO;
|
||||
import com.fruitshorts.user.vo.RegisterReqVO;
|
||||
import com.fruitshorts.user.vo.TokenRespVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* C 端账号:注册、登录、令牌轮换、登出。
|
||||
*
|
||||
* <p>几条硬规则:
|
||||
* <ul>
|
||||
* <li>access token 用 C 端独立密钥签发,与管理端 token 不可互换。</li>
|
||||
* <li>refresh token 只存哈希,刷新时轮换,旧的立即失效,防止被盗后长期复用。</li>
|
||||
* <li>封禁/注销账号直接拒绝登录,且状态变更时要吊销存量会话。</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppAuthService {
|
||||
|
||||
private final AppUserMapper appUserMapper;
|
||||
private final AppUserCredentialMapper credentialMapper;
|
||||
private final AppUserSessionMapper sessionMapper;
|
||||
private final AppUserConsentMapper consentMapper;
|
||||
private final AppUserDeviceMapper deviceMapper;
|
||||
private final AppOtpService appOtpService;
|
||||
private final CredentialHasher hasher;
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO register(RegisterReqVO req, String clientIp) {
|
||||
String targetType = AppIdentifier.normalizeType(req.getTargetType());
|
||||
String target = AppIdentifier.normalize(targetType, req.getTarget());
|
||||
appOtpService.consume(OtpScene.REGISTER.name(), targetType, target, req.getCode());
|
||||
|
||||
if (findCredential(targetType, target) != null) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录");
|
||||
}
|
||||
|
||||
AppUser user = createUser(req, clientIp);
|
||||
// created_at / updated_at 由数据库默认值维护,回读一次让响应里的时间字段不为空
|
||||
user = appUserMapper.selectById(user.getId());
|
||||
AppUserCredential credential = new AppUserCredential();
|
||||
credential.setUserId(user.getId());
|
||||
credential.setType(targetType);
|
||||
credential.setIdentifier(target);
|
||||
credential.setPasswordHash(StringUtils.hasText(req.getPassword())
|
||||
? passwordEncoder.encode(req.getPassword())
|
||||
: "");
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setFederationId("");
|
||||
credential.setAppId("");
|
||||
try {
|
||||
credentialMapper.insert(credential);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
// 并发注册:由唯一索引兜底,返回可理解的提示而不是 500
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "该账号已注册,请直接登录");
|
||||
}
|
||||
|
||||
recordConsent(user.getId(), "PRIVACY", req.getAgreementVersion(), clientIp, req.getDeviceId());
|
||||
recordConsent(user.getId(), "TERMS", req.getAgreementVersion(), clientIp, req.getDeviceId());
|
||||
if (Boolean.TRUE.equals(req.getMarketingConsent())) {
|
||||
recordConsent(user.getId(), "MARKETING", req.getAgreementVersion(), clientIp, req.getDeviceId());
|
||||
}
|
||||
touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel());
|
||||
|
||||
TokenRespVO resp = issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(),
|
||||
req.getOsVersion(), req.getAppVersion(), req.getPlatform(), clientIp);
|
||||
resp.setNewUser(true);
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO login(LoginReqVO req, String clientIp) {
|
||||
String targetType = AppIdentifier.normalizeType(req.getTargetType());
|
||||
String target = AppIdentifier.normalize(targetType, req.getTarget());
|
||||
boolean byCode = StringUtils.hasText(req.getCode());
|
||||
if (!byCode && !StringUtils.hasText(req.getPassword())) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "请提供验证码或密码");
|
||||
}
|
||||
if (byCode) {
|
||||
appOtpService.consume(OtpScene.LOGIN.name(), targetType, target, req.getCode());
|
||||
}
|
||||
|
||||
AppUserCredential credential = findCredential(targetType, target);
|
||||
if (credential == null) {
|
||||
throw new ServiceException(ErrorCode.LOGIN_FAILED, "账号不存在,请先注册");
|
||||
}
|
||||
if (!byCode && (!StringUtils.hasText(credential.getPasswordHash())
|
||||
|| !passwordEncoder.matches(req.getPassword(), credential.getPasswordHash()))) {
|
||||
throw new ServiceException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
|
||||
AppUser user = appUserMapper.selectById(credential.getUserId());
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (AppUserStatus.isBlocked(user.getStatus())) {
|
||||
throw new ServiceException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
credentialMapper.updateById(credential);
|
||||
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
user.setLastLoginIp(AppIdentifier.nullSafe(clientIp));
|
||||
appUserMapper.updateById(user);
|
||||
touchDevice(user.getId(), req.getDeviceId(), req.getPlatform(), req.getDeviceModel());
|
||||
|
||||
return issueTokens(user, credential, req.getDeviceId(), req.getDeviceModel(), req.getOsVersion(),
|
||||
req.getAppVersion(), req.getPlatform(), clientIp);
|
||||
}
|
||||
|
||||
/** 刷新令牌:轮换 refresh token,旧的立即失效。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO refresh(String refreshToken) {
|
||||
AppUserSession session = sessionMapper.selectOne(Wrappers.<AppUserSession>lambdaQuery()
|
||||
.eq(AppUserSession::getRefreshTokenHash, hasher.sha256(refreshToken))
|
||||
.last("limit 1"));
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
if (session == null || session.getRevokedAt() != null
|
||||
|| (session.getExpireAt() != null && session.getExpireAt().isBefore(now))) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录");
|
||||
}
|
||||
AppUser user = appUserMapper.selectById(session.getUserId());
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED);
|
||||
}
|
||||
if (AppUserStatus.isBlocked(user.getStatus())) {
|
||||
revokeAllSessions(user.getId());
|
||||
throw new ServiceException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
|
||||
String nextRefreshToken = hasher.newRefreshToken();
|
||||
session.setRefreshTokenHash(hasher.sha256(nextRefreshToken));
|
||||
session.setLastActiveAt(now);
|
||||
session.setExpireAt(now.plus(tokenProvider.appRefreshTtl()));
|
||||
sessionMapper.updateById(session);
|
||||
|
||||
TokenRespVO resp = new TokenRespVO();
|
||||
resp.setAccessToken(tokenProvider.createAppToken(user.getId(), user.getUid(), session.getDeviceId()));
|
||||
resp.setRefreshToken(nextRefreshToken);
|
||||
resp.setExpiresIn(tokenProvider.accessTtlSeconds());
|
||||
resp.setUser(toResp(user, findCredentialByUser(user.getId())));
|
||||
return resp;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void logout(Long userId, LogoutReqVO req) {
|
||||
if (userId == null) {
|
||||
return;
|
||||
}
|
||||
if (Boolean.TRUE.equals(req.getAllDevices())) {
|
||||
revokeAllSessions(userId);
|
||||
return;
|
||||
}
|
||||
if (!StringUtils.hasText(req.getRefreshToken())) {
|
||||
return;
|
||||
}
|
||||
AppUserSession session = sessionMapper.selectOne(Wrappers.<AppUserSession>lambdaQuery()
|
||||
.eq(AppUserSession::getUserId, userId)
|
||||
.eq(AppUserSession::getRefreshTokenHash, hasher.sha256(req.getRefreshToken()))
|
||||
.last("limit 1"));
|
||||
if (session != null && session.getRevokedAt() == null) {
|
||||
session.setRevokedAt(LocalDateTime.now());
|
||||
sessionMapper.updateById(session);
|
||||
}
|
||||
}
|
||||
|
||||
/** 封禁、注销、改密后必须调用,否则旧 token 在有效期内仍然可用。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void revokeAllSessions(Long userId) {
|
||||
sessionMapper.update(null, Wrappers.<AppUserSession>lambdaUpdate()
|
||||
.eq(AppUserSession::getUserId, userId)
|
||||
.isNull(AppUserSession::getRevokedAt)
|
||||
.set(AppUserSession::getRevokedAt, LocalDateTime.now()));
|
||||
}
|
||||
|
||||
public AppUserCredential findCredential(String type, String identifier) {
|
||||
return credentialMapper.selectOne(Wrappers.<AppUserCredential>lambdaQuery()
|
||||
.eq(AppUserCredential::getType, type)
|
||||
.eq(AppUserCredential::getIdentifier, identifier)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
public AppUserCredential findCredentialByUser(Long userId) {
|
||||
return credentialMapper.selectOne(Wrappers.<AppUserCredential>lambdaQuery()
|
||||
.eq(AppUserCredential::getUserId, userId)
|
||||
.orderByAsc(AppUserCredential::getId)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
public List<AppUserCredential> credentialsOf(Long userId) {
|
||||
return credentialMapper.selectList(Wrappers.<AppUserCredential>lambdaQuery()
|
||||
.eq(AppUserCredential::getUserId, userId)
|
||||
.orderByAsc(AppUserCredential::getId));
|
||||
}
|
||||
|
||||
public AppUserRespVO toResp(AppUser user, AppUserCredential credential) {
|
||||
AppUserRespVO vo = new AppUserRespVO();
|
||||
vo.setUid(user.getUid());
|
||||
vo.setNickname(user.getNickname());
|
||||
vo.setAvatarUrl(user.getAvatarUrl());
|
||||
vo.setGender(user.getGender());
|
||||
vo.setCountryCode(user.getCountryCode());
|
||||
vo.setRegionCode(user.getRegionCode());
|
||||
vo.setLanguage(user.getLanguage());
|
||||
vo.setTimezone(user.getTimezone());
|
||||
vo.setCurrency(user.getCurrency());
|
||||
vo.setStatus(user.getStatus());
|
||||
vo.setCreatedAt(user.getCreatedAt());
|
||||
vo.setLastLoginAt(user.getLastLoginAt());
|
||||
vo.setVipLevel(user.getVipLevel());
|
||||
vo.setVipExpireAt(user.getVipExpireAt());
|
||||
if (credential != null) {
|
||||
vo.setMaskedTarget(AppIdentifier.mask(credential.getType(), credential.getIdentifier()));
|
||||
}
|
||||
return vo;
|
||||
}
|
||||
|
||||
private TokenRespVO issueTokens(AppUser user, AppUserCredential credential, String deviceId,
|
||||
String deviceModel, String osVersion, String appVersion,
|
||||
String platform, String clientIp) {
|
||||
String refreshToken = hasher.newRefreshToken();
|
||||
AppUserSession session = new AppUserSession();
|
||||
session.setUserId(user.getId());
|
||||
session.setRefreshTokenHash(hasher.sha256(refreshToken));
|
||||
session.setDeviceId(AppIdentifier.nullSafe(deviceId));
|
||||
session.setDeviceModel(AppIdentifier.nullSafe(deviceModel));
|
||||
session.setOsVersion(AppIdentifier.nullSafe(osVersion));
|
||||
session.setAppVersion(AppIdentifier.nullSafe(appVersion));
|
||||
session.setPlatform(AppIdentifier.nullSafe(platform));
|
||||
session.setIp(AppIdentifier.nullSafe(clientIp));
|
||||
session.setUserAgent("");
|
||||
session.setExpireAt(LocalDateTime.now().plus(tokenProvider.appRefreshTtl()));
|
||||
session.setLastActiveAt(LocalDateTime.now());
|
||||
sessionMapper.insert(session);
|
||||
|
||||
TokenRespVO resp = new TokenRespVO();
|
||||
resp.setAccessToken(tokenProvider.createAppToken(user.getId(), user.getUid(), session.getDeviceId()));
|
||||
resp.setRefreshToken(refreshToken);
|
||||
resp.setExpiresIn(tokenProvider.accessTtlSeconds());
|
||||
resp.setUser(toResp(user, credential));
|
||||
return resp;
|
||||
}
|
||||
|
||||
private AppUser createUser(RegisterReqVO req, String clientIp) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
user.setNickname(StringUtils.hasText(req.getNickname())
|
||||
? req.getNickname()
|
||||
: "User" + user.getUid().substring(1, 7));
|
||||
user.setAvatarUrl("");
|
||||
user.setGender("");
|
||||
String country = StringUtils.hasText(req.getCountryCode()) ? req.getCountryCode() : "ID";
|
||||
user.setCountryCode(country);
|
||||
user.setRegionCode(country);
|
||||
user.setLanguage("id-ID");
|
||||
user.setTimezone("Asia/Jakarta");
|
||||
user.setCurrency("IDR");
|
||||
user.setStatus(AppUserStatus.NORMAL);
|
||||
user.setRegisterSource("APP");
|
||||
user.setRegisterChannel("");
|
||||
user.setRegisterDeviceId(AppIdentifier.nullSafe(req.getDeviceId()));
|
||||
user.setRegisterIp(AppIdentifier.nullSafe(clientIp));
|
||||
user.setAgeVerified(0);
|
||||
user.setAgreementVersion(AppIdentifier.nullSafe(req.getAgreementVersion()));
|
||||
user.setAgreementAt(LocalDateTime.now());
|
||||
user.setVipLevel(0);
|
||||
user.setRiskLevel(0);
|
||||
user.setInviteCode("");
|
||||
user.setReferrerUserId(0L);
|
||||
appUserMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
private void recordConsent(Long userId, String scene, String version, String clientIp, String deviceId) {
|
||||
AppUserConsent consent = new AppUserConsent();
|
||||
consent.setUserId(userId);
|
||||
consent.setScene(scene);
|
||||
consent.setVersion(AppIdentifier.nullSafe(version));
|
||||
consent.setAgreed(1);
|
||||
consent.setIp(AppIdentifier.nullSafe(clientIp));
|
||||
consent.setDeviceId(AppIdentifier.nullSafe(deviceId));
|
||||
consentMapper.insert(consent);
|
||||
}
|
||||
|
||||
private void touchDevice(Long userId, String deviceId, String platform, String deviceModel) {
|
||||
if (!StringUtils.hasText(deviceId)) {
|
||||
return;
|
||||
}
|
||||
AppUserDevice device = deviceMapper.selectOne(Wrappers.<AppUserDevice>lambdaQuery()
|
||||
.eq(AppUserDevice::getUserId, userId)
|
||||
.eq(AppUserDevice::getDeviceId, deviceId)
|
||||
.last("limit 1"));
|
||||
if (device == null) {
|
||||
device = new AppUserDevice();
|
||||
device.setUserId(userId);
|
||||
device.setDeviceId(deviceId);
|
||||
device.setPlatform(AppIdentifier.nullSafe(platform));
|
||||
device.setDeviceModel(AppIdentifier.nullSafe(deviceModel));
|
||||
device.setFirstSeenAt(LocalDateTime.now());
|
||||
device.setLastSeenAt(LocalDateTime.now());
|
||||
device.setStatus("active");
|
||||
deviceMapper.insert(device);
|
||||
return;
|
||||
}
|
||||
device.setLastSeenAt(LocalDateTime.now());
|
||||
deviceMapper.updateById(device);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.user.enums.CredentialType;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/** 手机号/邮箱的规范化与脱敏。 */
|
||||
public final class AppIdentifier {
|
||||
|
||||
private AppIdentifier() {
|
||||
}
|
||||
|
||||
public static String normalizeType(String raw) {
|
||||
String type = raw == null ? "" : raw.trim().toUpperCase(Locale.ROOT);
|
||||
if (!CredentialType.PHONE.name().equals(type) && !CredentialType.EMAIL.name().equals(type)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "仅支持手机号或邮箱");
|
||||
}
|
||||
return type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手机号统一成 E.164;首发市场是印尼,所以本地 08xx 补 62 前缀,
|
||||
* 其余情况按国际格式补 +。
|
||||
*/
|
||||
public static String normalize(String targetType, String raw) {
|
||||
if (!StringUtils.hasText(raw)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "请输入手机号或邮箱");
|
||||
}
|
||||
String value = raw.trim();
|
||||
if (CredentialType.EMAIL.name().equals(targetType)) {
|
||||
return value.toLowerCase(Locale.ROOT);
|
||||
}
|
||||
String digits = value.replaceAll("[^0-9+]", "");
|
||||
if (digits.startsWith("+")) {
|
||||
return digits;
|
||||
}
|
||||
if (digits.startsWith("0")) {
|
||||
return "+62" + digits.substring(1);
|
||||
}
|
||||
return "+" + digits;
|
||||
}
|
||||
|
||||
public static String mask(String targetType, String identifier) {
|
||||
if (!StringUtils.hasText(identifier)) {
|
||||
return "";
|
||||
}
|
||||
if (CredentialType.EMAIL.name().equals(targetType)) {
|
||||
int at = identifier.indexOf('@');
|
||||
if (at <= 0) {
|
||||
return "***";
|
||||
}
|
||||
return identifier.charAt(0) + "***" + identifier.substring(at);
|
||||
}
|
||||
if (identifier.length() <= 7) {
|
||||
return "****";
|
||||
}
|
||||
return identifier.substring(0, 4) + "****" + identifier.substring(identifier.length() - 3);
|
||||
}
|
||||
|
||||
public static String nullSafe(String value) {
|
||||
return value == null ? "" : value;
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.user.dal.entity.AppUserOtp;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserOtpMapper;
|
||||
import com.fruitshorts.user.enums.OtpScene;
|
||||
import com.fruitshorts.user.vo.OtpSendReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.Locale;
|
||||
|
||||
/**
|
||||
* 验证码服务:发送频控 + 校验消费。
|
||||
*
|
||||
* <p>频控是登录链路的第一道防线:短信成本与刷号风险都在这里拦住。
|
||||
* 阈值先按保守值给,上线后按真实到达率与风控数据调。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppOtpService {
|
||||
|
||||
private static final int OTP_TTL_MINUTES = 5;
|
||||
private static final int OTP_RESEND_SECONDS = 60;
|
||||
private static final int OTP_DAILY_LIMIT_PER_TARGET = 10;
|
||||
private static final int OTP_HOURLY_LIMIT_PER_IP = 20;
|
||||
private static final int OTP_MAX_VERIFY_FAIL = 5;
|
||||
|
||||
private final AppUserOtpMapper otpMapper;
|
||||
private final OtpSender otpSender;
|
||||
private final CredentialHasher hasher;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void send(OtpSendReqVO req, String clientIp) {
|
||||
String scene = req.getScene() == null ? "" : req.getScene().trim().toUpperCase(Locale.ROOT);
|
||||
if (!OtpScene.isValid(scene)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的验证码场景");
|
||||
}
|
||||
String targetType = AppIdentifier.normalizeType(req.getTargetType());
|
||||
String target = AppIdentifier.normalize(targetType, req.getTarget());
|
||||
checkFrequency(target, clientIp);
|
||||
|
||||
String code = hasher.newOtpCode();
|
||||
AppUserOtp record = new AppUserOtp();
|
||||
record.setScene(scene);
|
||||
record.setTargetType(targetType);
|
||||
record.setTarget(target);
|
||||
record.setCodeHash(hasher.hashOtp(code));
|
||||
record.setExpireAt(LocalDateTime.now().plusMinutes(OTP_TTL_MINUTES));
|
||||
record.setSendIp(AppIdentifier.nullSafe(clientIp));
|
||||
record.setSendDeviceId(AppIdentifier.nullSafe(req.getDeviceId()));
|
||||
record.setSendCount(1);
|
||||
record.setFailCount(0);
|
||||
otpMapper.insert(record);
|
||||
|
||||
otpSender.send(targetType, target, scene, code);
|
||||
}
|
||||
|
||||
/** 校验通过立即标记已使用,保证一个验证码只能用一次。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void consume(String scene, String targetType, String target, String code) {
|
||||
AppUserOtp record = otpMapper.selectOne(Wrappers.<AppUserOtp>lambdaQuery()
|
||||
.eq(AppUserOtp::getTargetType, targetType)
|
||||
.eq(AppUserOtp::getTarget, target)
|
||||
.eq(AppUserOtp::getScene, scene)
|
||||
.isNull(AppUserOtp::getUsedAt)
|
||||
.orderByDesc(AppUserOtp::getId)
|
||||
.last("limit 1"));
|
||||
if (record == null) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "请先获取验证码");
|
||||
}
|
||||
if (record.getExpireAt() != null && record.getExpireAt().isBefore(LocalDateTime.now())) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "验证码已过期");
|
||||
}
|
||||
if (record.getFailCount() != null && record.getFailCount() >= OTP_MAX_VERIFY_FAIL) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "验证码错误次数过多,请重新获取");
|
||||
}
|
||||
if (!hasher.hashOtp(code).equals(record.getCodeHash())) {
|
||||
record.setFailCount((record.getFailCount() == null ? 0 : record.getFailCount()) + 1);
|
||||
otpMapper.updateById(record);
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "验证码不正确");
|
||||
}
|
||||
record.setUsedAt(LocalDateTime.now());
|
||||
otpMapper.updateById(record);
|
||||
}
|
||||
|
||||
private void checkFrequency(String target, String clientIp) {
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
AppUserOtp last = otpMapper.selectOne(Wrappers.<AppUserOtp>lambdaQuery()
|
||||
.eq(AppUserOtp::getTarget, target)
|
||||
.orderByDesc(AppUserOtp::getId)
|
||||
.last("limit 1"));
|
||||
if (last != null && last.getCreatedAt() != null
|
||||
&& last.getCreatedAt().isAfter(now.minusSeconds(OTP_RESEND_SECONDS))) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "验证码发送过于频繁,请稍后再试");
|
||||
}
|
||||
Long todayCount = otpMapper.selectCount(Wrappers.<AppUserOtp>lambdaQuery()
|
||||
.eq(AppUserOtp::getTarget, target)
|
||||
.ge(AppUserOtp::getCreatedAt, now.toLocalDate().atStartOfDay()));
|
||||
if (todayCount != null && todayCount >= OTP_DAILY_LIMIT_PER_TARGET) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "今日验证码次数已达上限");
|
||||
}
|
||||
if (StringUtils.hasText(clientIp)) {
|
||||
Long ipCount = otpMapper.selectCount(Wrappers.<AppUserOtp>lambdaQuery()
|
||||
.eq(AppUserOtp::getSendIp, clientIp)
|
||||
.ge(AppUserOtp::getCreatedAt, now.minusHours(1)));
|
||||
if (ipCount != null && ipCount >= OTP_HOURLY_LIMIT_PER_IP) {
|
||||
throw new ServiceException(ErrorCode.TOO_MANY_REQUESTS, "当前网络请求过于频繁");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+155
@@ -0,0 +1,155 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.metadata.IPage;
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.common.pojo.PageResult;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.entity.AppUserBanRecord;
|
||||
import com.fruitshorts.user.dal.entity.AppUserCredential;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserBanRecordMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserCredentialMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import com.fruitshorts.user.vo.AppUserQueryReqVO;
|
||||
import com.fruitshorts.user.vo.AppUserAdminRespVO;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import org.springframework.beans.BeanUtils;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 后台用户管理:检索、详情、状态处置。
|
||||
*
|
||||
* <p>所有状态变更都会吊销该用户全部会话,避免"封了但还能用"的情况。
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppUserAdminService {
|
||||
|
||||
private final AppUserMapper appUserMapper;
|
||||
private final AppUserCredentialMapper credentialMapper;
|
||||
private final AppUserBanRecordMapper banRecordMapper;
|
||||
private final AppAuthService authService;
|
||||
|
||||
public PageResult<AppUserAdminRespVO> page(AppUserQueryReqVO req) {
|
||||
Page<AppUser> page = new Page<>(
|
||||
req.getPageIndex() == null || req.getPageIndex() < 1 ? 1 : req.getPageIndex(),
|
||||
req.getPageSize() == null || req.getPageSize() < 1 ? 10 : req.getPageSize());
|
||||
|
||||
IPage<AppUser> result;
|
||||
if (StringUtils.hasText(req.getKeyword())) {
|
||||
// uid / 昵称 直接查;手机号与邮箱要先在凭证表命中 userId
|
||||
List<Long> matchedUserIds = userIdsByCredential(req.getKeyword());
|
||||
result = appUserMapper.selectPage(page, Wrappers.<AppUser>lambdaQuery()
|
||||
.and(wrapper -> {
|
||||
wrapper.like(AppUser::getUid, req.getKeyword())
|
||||
.or().like(AppUser::getNickname, req.getKeyword());
|
||||
if (!matchedUserIds.isEmpty()) {
|
||||
wrapper.or().in(AppUser::getId, matchedUserIds);
|
||||
}
|
||||
})
|
||||
.eq(StringUtils.hasText(req.getStatus()), AppUser::getStatus, req.getStatus())
|
||||
.eq(StringUtils.hasText(req.getCountryCode()), AppUser::getCountryCode,
|
||||
req.getCountryCode())
|
||||
.eq(StringUtils.hasText(req.getRegisterSource()), AppUser::getRegisterSource,
|
||||
req.getRegisterSource())
|
||||
.orderByDesc(AppUser::getId));
|
||||
} else {
|
||||
result = appUserMapper.selectPage(page, Wrappers.<AppUser>lambdaQuery()
|
||||
.eq(StringUtils.hasText(req.getStatus()), AppUser::getStatus, req.getStatus())
|
||||
.eq(StringUtils.hasText(req.getCountryCode()), AppUser::getCountryCode,
|
||||
req.getCountryCode())
|
||||
.eq(StringUtils.hasText(req.getRegisterSource()), AppUser::getRegisterSource,
|
||||
req.getRegisterSource())
|
||||
.orderByDesc(AppUser::getId));
|
||||
}
|
||||
return new PageResult<>(result.getTotal(), toRespList(result.getRecords()));
|
||||
}
|
||||
|
||||
public AppUserAdminRespVO detail(Long id) {
|
||||
AppUser user = appUserMapper.selectById(id);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return toAdminResp(user);
|
||||
}
|
||||
|
||||
/** 修改账号状态。封禁/注销会立即踢下线。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void updateStatus(Long id, String status, String reason, Long operatorId) {
|
||||
if (!AppUserStatus.ALL.contains(status)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "不支持的账号状态: " + status);
|
||||
}
|
||||
AppUser user = appUserMapper.selectById(id);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在");
|
||||
}
|
||||
user.setStatus(status);
|
||||
appUserMapper.updateById(user);
|
||||
if (AppUserStatus.isBlocked(status)) {
|
||||
authService.revokeAllSessions(id);
|
||||
}
|
||||
if (AppUserStatus.BANNED.equals(status) || AppUserStatus.LIMITED.equals(status)) {
|
||||
AppUserBanRecord record = new AppUserBanRecord();
|
||||
record.setUserId(id);
|
||||
record.setType(AppUserStatus.BANNED.equals(status) ? "BAN" : "LIMIT");
|
||||
record.setReasonCode("");
|
||||
record.setReason(StringUtils.hasText(reason) ? reason : "");
|
||||
record.setRefType("ADMIN");
|
||||
record.setRefId(0L);
|
||||
record.setStartAt(LocalDateTime.now());
|
||||
record.setOperatorId(operatorId == null ? 0L : operatorId);
|
||||
record.setAppealStatus("none");
|
||||
record.setAppealContent("");
|
||||
record.setHandleBy(0L);
|
||||
record.setHandleNote("");
|
||||
banRecordMapper.insert(record);
|
||||
}
|
||||
}
|
||||
|
||||
public List<AppUserBanRecord> banRecords(Long userId) {
|
||||
return banRecordMapper.selectList(Wrappers.<AppUserBanRecord>lambdaQuery()
|
||||
.eq(AppUserBanRecord::getUserId, userId)
|
||||
.orderByDesc(AppUserBanRecord::getId));
|
||||
}
|
||||
|
||||
private List<Long> userIdsByCredential(String keyword) {
|
||||
List<AppUserCredential> credentials = credentialMapper.selectList(
|
||||
Wrappers.<AppUserCredential>lambdaQuery()
|
||||
.like(AppUserCredential::getIdentifier, keyword.trim().toLowerCase())
|
||||
.orderByDesc(AppUserCredential::getId)
|
||||
.last("limit 50"));
|
||||
List<Long> ids = new ArrayList<>();
|
||||
for (AppUserCredential credential : credentials) {
|
||||
if (!ids.contains(credential.getUserId())) {
|
||||
ids.add(credential.getUserId());
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
private List<AppUserAdminRespVO> toRespList(List<AppUser> users) {
|
||||
List<AppUserAdminRespVO> list = new ArrayList<>(users.size());
|
||||
for (AppUser user : users) {
|
||||
list.add(toAdminResp(user));
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
private AppUserAdminRespVO toAdminResp(AppUser user) {
|
||||
AppUserRespVO base = authService.toResp(user, authService.findCredentialByUser(user.getId()));
|
||||
AppUserAdminRespVO vo = new AppUserAdminRespVO();
|
||||
BeanUtils.copyProperties(base, vo);
|
||||
vo.setId(user.getId());
|
||||
return vo;
|
||||
}
|
||||
}
|
||||
+147
@@ -0,0 +1,147 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.user.dal.entity.AppAccountDeletion;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.entity.AppUserConsent;
|
||||
import com.fruitshorts.user.dal.entity.AppUserDevice;
|
||||
import com.fruitshorts.user.dal.mapper.AppAccountDeletionMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserConsentMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserDeviceMapper;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.enums.DeletionStatus;
|
||||
import com.fruitshorts.user.vo.AppUserRespVO;
|
||||
import com.fruitshorts.user.vo.DeactivateReqVO;
|
||||
import com.fruitshorts.user.vo.ProfileUpdateReqVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** C 端用户资料、设备、注销与数据导出。 */
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AppUserService {
|
||||
|
||||
/** 注销冷静期天数。商店合规要求可撤销,且期间数据仍可用。 */
|
||||
private static final int DELETION_COOLING_OFF_DAYS = 15;
|
||||
|
||||
private final AppUserMapper appUserMapper;
|
||||
private final AppUserConsentMapper consentMapper;
|
||||
private final AppUserDeviceMapper deviceMapper;
|
||||
private final AppAccountDeletionMapper deletionMapper;
|
||||
private final AppAuthService authService;
|
||||
|
||||
public AppUser getExisting(Long userId) {
|
||||
AppUser user = userId == null ? null : appUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.DATA_NOT_FOUND, "用户不存在");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
public AppUserRespVO me(Long userId) {
|
||||
AppUser user = getExisting(userId);
|
||||
return authService.toResp(user, authService.findCredentialByUser(userId));
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public AppUserRespVO updateProfile(Long userId, ProfileUpdateReqVO req) {
|
||||
AppUser user = getExisting(userId);
|
||||
if (StringUtils.hasText(req.getNickname())) {
|
||||
user.setNickname(req.getNickname().trim());
|
||||
}
|
||||
if (req.getAvatarUrl() != null) {
|
||||
user.setAvatarUrl(req.getAvatarUrl().trim());
|
||||
}
|
||||
if (req.getGender() != null) {
|
||||
user.setGender(req.getGender().trim());
|
||||
}
|
||||
if (req.getBirthday() != null) {
|
||||
user.setBirthday(req.getBirthday());
|
||||
}
|
||||
if (StringUtils.hasText(req.getLanguage())) {
|
||||
user.setLanguage(req.getLanguage().trim());
|
||||
}
|
||||
if (StringUtils.hasText(req.getTimezone())) {
|
||||
user.setTimezone(req.getTimezone().trim());
|
||||
}
|
||||
if (StringUtils.hasText(req.getRegionCode())) {
|
||||
user.setRegionCode(req.getRegionCode().trim());
|
||||
}
|
||||
appUserMapper.updateById(user);
|
||||
return authService.toResp(user, authService.findCredentialByUser(userId));
|
||||
}
|
||||
|
||||
public List<AppUserDevice> devices(Long userId) {
|
||||
return deviceMapper.selectList(Wrappers.<AppUserDevice>lambdaQuery()
|
||||
.eq(AppUserDevice::getUserId, userId)
|
||||
.orderByDesc(AppUserDevice::getLastSeenAt));
|
||||
}
|
||||
|
||||
public AppAccountDeletion pendingDeletion(Long userId) {
|
||||
return deletionMapper.selectOne(Wrappers.<AppAccountDeletion>lambdaQuery()
|
||||
.eq(AppAccountDeletion::getUserId, userId)
|
||||
.eq(AppAccountDeletion::getStatus, DeletionStatus.PENDING)
|
||||
.last("limit 1"));
|
||||
}
|
||||
|
||||
/**
|
||||
* 申请注销:立即登出全部设备并进入冷静期,冷静期内可撤销。
|
||||
* 真正的数据清理由定时任务在 scheduledAt 之后执行。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public LocalDateTime requestDeactivation(Long userId, DeactivateReqVO req) {
|
||||
AppUser user = getExisting(userId);
|
||||
if (pendingDeletion(userId) != null) {
|
||||
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "注销申请已在处理中");
|
||||
}
|
||||
LocalDateTime scheduledAt = LocalDateTime.now().plusDays(DELETION_COOLING_OFF_DAYS);
|
||||
AppAccountDeletion record = new AppAccountDeletion();
|
||||
record.setUserId(user.getId());
|
||||
record.setStatus(DeletionStatus.PENDING);
|
||||
record.setReason(req == null || req.getReason() == null ? "" : req.getReason());
|
||||
record.setRequestedAt(LocalDateTime.now());
|
||||
record.setScheduledAt(scheduledAt);
|
||||
record.setOperatorId(0L);
|
||||
deletionMapper.insert(record);
|
||||
authService.revokeAllSessions(userId);
|
||||
return scheduledAt;
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void cancelDeactivation(Long userId) {
|
||||
AppAccountDeletion record = pendingDeletion(userId);
|
||||
if (record == null) {
|
||||
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "没有待处理的注销申请");
|
||||
}
|
||||
record.setStatus(DeletionStatus.CANCELLED);
|
||||
record.setCancelledAt(LocalDateTime.now());
|
||||
deletionMapper.updateById(record);
|
||||
}
|
||||
|
||||
/** 数据导出(商店与隐私法规要求):账号侧数据;内容侧数据由接口层补齐。 */
|
||||
public Map<String, Object> exportAccount(Long userId) {
|
||||
AppUser user = getExisting(userId);
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("profile", authService.toResp(user, null));
|
||||
data.put("credentials", authService.credentialsOf(userId).stream()
|
||||
.map(item -> Map.of(
|
||||
"type", item.getType(),
|
||||
"masked", AppIdentifier.mask(item.getType(), item.getIdentifier()),
|
||||
"verified", item.getVerified() != null && item.getVerified() == 1))
|
||||
.toList());
|
||||
data.put("consents", consentMapper.selectList(Wrappers.<AppUserConsent>lambdaQuery()
|
||||
.eq(AppUserConsent::getUserId, userId)
|
||||
.orderByAsc(AppUserConsent::getId)));
|
||||
data.put("devices", devices(userId));
|
||||
return data;
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fruitshorts.framework.security.AppUserStatusChecker;
|
||||
import com.fruitshorts.user.dal.entity.AppUser;
|
||||
import com.fruitshorts.user.dal.mapper.AppUserMapper;
|
||||
import com.fruitshorts.user.enums.AppUserStatus;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 账号状态校验:走主键查询。
|
||||
* 接入 Redis 后这里加一层 5–10 秒的短缓存,既能挡住被处置账号,
|
||||
* 又不会让每个请求都打库。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AppUserStatusCheckerImpl implements AppUserStatusChecker {
|
||||
|
||||
private final AppUserMapper appUserMapper;
|
||||
|
||||
@Override
|
||||
public boolean isActive(Long userId) {
|
||||
if (userId == null) {
|
||||
return false;
|
||||
}
|
||||
AppUser user = appUserMapper.selectById(userId);
|
||||
return user != null && !AppUserStatus.isBlocked(user.getStatus());
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fruitshorts.framework.security.JwtProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
|
||||
/**
|
||||
* 凭证哈希工具。
|
||||
*
|
||||
* <p>验证码只有 6 位,直接 SHA-256 在库泄露后可被瞬间爆破,所以用服务端密钥做 HMAC(加盐):
|
||||
* 没有密钥就无法离线枚举。refresh token 是 256 位随机串,用 SHA-256 即可。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class CredentialHasher {
|
||||
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final JwtProperties jwtProperties;
|
||||
|
||||
public String hashOtp(String code) {
|
||||
return hmac("otp|" + code);
|
||||
}
|
||||
|
||||
public String sha256(String value) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(digest.digest(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("哈希失败", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public String newRefreshToken() {
|
||||
byte[] bytes = new byte[32];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
|
||||
}
|
||||
|
||||
public String newOtpCode() {
|
||||
return String.format("%06d", RANDOM.nextInt(1_000_000));
|
||||
}
|
||||
|
||||
/** 对外用户标识:不用自增 ID,避免被枚举出用户规模。 */
|
||||
public String newUid() {
|
||||
byte[] bytes = new byte[6];
|
||||
RANDOM.nextBytes(bytes);
|
||||
return "U" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes).toUpperCase();
|
||||
}
|
||||
|
||||
private String hmac(String value) {
|
||||
try {
|
||||
byte[] key = (jwtProperties.getAppSecret() + "|credential-pepper").getBytes(StandardCharsets.UTF_8);
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(key, "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ex) {
|
||||
throw new IllegalStateException("验证码哈希失败", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 开发用发送器:只打日志,不真正发短信。
|
||||
* 生产环境把 fruitshorts.otp.sender 配成 sms / email 并实现对应的 OtpSender。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "fruitshorts.otp.sender", havingValue = "log", matchIfMissing = true)
|
||||
public class LogOtpSender implements OtpSender {
|
||||
|
||||
@Override
|
||||
public void send(String targetType, String target, String scene, String code) {
|
||||
log.info("[OTP] 场景={} 渠道={} 接收方={} 验证码={}", scene, targetType, mask(target), code);
|
||||
}
|
||||
|
||||
private String mask(String target) {
|
||||
if (target == null || target.length() <= 6) {
|
||||
return "****";
|
||||
}
|
||||
return target.substring(0, 3) + "****" + target.substring(target.length() - 3);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
/**
|
||||
* 验证码投递通道。第一阶段只有日志实现,接入供应商时新增一个实现类即可,
|
||||
* 需要多供应商轮询时在实现里做路由,业务代码不感知。
|
||||
*/
|
||||
public interface OtpSender {
|
||||
|
||||
/**
|
||||
* @param targetType PHONE/EMAIL
|
||||
* @param target E.164 手机号或邮箱
|
||||
* @param scene REGISTER/LOGIN/RESET_PWD/BIND
|
||||
* @param code 明文验证码,实现方负责按渠道模板组装内容
|
||||
*/
|
||||
void send(String targetType, String target, String scene, String code);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
import lombok.EqualsAndHashCode;
|
||||
|
||||
/**
|
||||
* 后台用户视图。
|
||||
*
|
||||
* <p>比客户端那份多一个自增 id —— 后台的详情、封禁、流水接口都按 id 寻址;
|
||||
* 客户端侧仍然只认 uid,不暴露自增 ID(能被枚举出用户规模)。
|
||||
*/
|
||||
@Data
|
||||
@EqualsAndHashCode(callSuper = true)
|
||||
public class AppUserAdminRespVO extends AppUserRespVO {
|
||||
|
||||
private Long id;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 后台用户检索条件。 */
|
||||
@Data
|
||||
public class AppUserQueryReqVO {
|
||||
|
||||
/** uid / 昵称 / 手机号 / 邮箱 模糊匹配 */
|
||||
private String keyword;
|
||||
private String status;
|
||||
private String countryCode;
|
||||
private String registerSource;
|
||||
private Integer pageIndex = 1;
|
||||
private Integer pageSize = 10;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/** 对外用户信息,只暴露 uid,不含自增 ID 与手机号明文。 */
|
||||
@Data
|
||||
public class AppUserRespVO {
|
||||
|
||||
private String uid;
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String gender;
|
||||
private String countryCode;
|
||||
private String regionCode;
|
||||
private String language;
|
||||
private String timezone;
|
||||
private String currency;
|
||||
private String status;
|
||||
/** 脱敏后的手机号/邮箱,用于"我的"页展示 */
|
||||
private String maskedTarget;
|
||||
private LocalDateTime createdAt;
|
||||
private LocalDateTime lastLoginAt;
|
||||
/** 预留:会员等级与到期(第二阶段) */
|
||||
private Integer vipLevel;
|
||||
private LocalDateTime vipExpireAt;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 申请注销:进入冷静期,期间可撤销。 */
|
||||
@Data
|
||||
public class DeactivateReqVO {
|
||||
|
||||
private String reason;
|
||||
private String password;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 手机号 / 邮箱登录:验证码与密码二选一。 */
|
||||
@Data
|
||||
public class LoginReqVO {
|
||||
|
||||
@NotBlank(message = "请指定登录方式")
|
||||
private String targetType;
|
||||
|
||||
@NotBlank(message = "请输入手机号或邮箱")
|
||||
private String target;
|
||||
|
||||
/** 验证码登录 */
|
||||
private String code;
|
||||
|
||||
/** 密码登录 */
|
||||
private String password;
|
||||
|
||||
private String deviceId;
|
||||
private String deviceModel;
|
||||
private String osVersion;
|
||||
private String appVersion;
|
||||
private String platform;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 登出。allDevices=true 时吊销该用户全部会话。 */
|
||||
@Data
|
||||
public class LogoutReqVO {
|
||||
|
||||
private String refreshToken;
|
||||
private Boolean allDevices;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 发送验证码。 */
|
||||
@Data
|
||||
public class OtpSendReqVO {
|
||||
|
||||
/** REGISTER/LOGIN/RESET_PWD/BIND */
|
||||
@NotBlank(message = "请指定验证码场景")
|
||||
private String scene;
|
||||
|
||||
/** PHONE/EMAIL */
|
||||
@NotBlank(message = "请指定验证方式")
|
||||
private String targetType;
|
||||
|
||||
/** E.164 手机号或邮箱 */
|
||||
@NotBlank(message = "请输入手机号或邮箱")
|
||||
private String target;
|
||||
|
||||
private String deviceId;
|
||||
|
||||
/** 预留:图形验证码票据,触发频控阈值后必填 */
|
||||
private String captchaTicket;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDate;
|
||||
|
||||
/** 基础资料修改。所有字段可选,只更新传入的部分。 */
|
||||
@Data
|
||||
public class ProfileUpdateReqVO {
|
||||
|
||||
private String nickname;
|
||||
private String avatarUrl;
|
||||
private String gender;
|
||||
private LocalDate birthday;
|
||||
private String language;
|
||||
private String timezone;
|
||||
private String regionCode;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 用 refresh token 换新的 access token,并轮换 refresh token。 */
|
||||
@Data
|
||||
public class RefreshReqVO {
|
||||
|
||||
@NotBlank(message = "缺少 refreshToken")
|
||||
private String refreshToken;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import lombok.Data;
|
||||
|
||||
/** 手机号 / 邮箱注册。 */
|
||||
@Data
|
||||
public class RegisterReqVO {
|
||||
|
||||
@NotBlank(message = "请指定注册方式")
|
||||
private String targetType;
|
||||
|
||||
@NotBlank(message = "请输入手机号或邮箱")
|
||||
private String target;
|
||||
|
||||
@NotBlank(message = "请输入验证码")
|
||||
private String code;
|
||||
|
||||
/** 可选:同时设置密码,之后可用密码登录 */
|
||||
private String password;
|
||||
|
||||
private String nickname;
|
||||
private String countryCode;
|
||||
private String deviceId;
|
||||
private String deviceModel;
|
||||
private String osVersion;
|
||||
private String appVersion;
|
||||
private String platform;
|
||||
|
||||
/** 协议版本,注册即视为同意最新版隐私政策与用户协议 */
|
||||
private String agreementVersion;
|
||||
|
||||
/** 预留:营销与个性化广告同意,未同意不影响注册 */
|
||||
private Boolean marketingConsent;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.fruitshorts.user.vo;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
/** 登录/刷新返回的令牌与用户摘要。 */
|
||||
@Data
|
||||
public class TokenRespVO {
|
||||
|
||||
private String accessToken;
|
||||
/** 不透明随机串,只在客户端保存,服务端只存哈希 */
|
||||
private String refreshToken;
|
||||
/** access token 剩余有效期(秒) */
|
||||
private long expiresIn;
|
||||
private String tokenType = "Bearer";
|
||||
private AppUserRespVO user;
|
||||
/** 本次登录是否新建了账号(验证码登录可能顺带注册) */
|
||||
private boolean newUser;
|
||||
}
|
||||
Reference in new issue
Block a user