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:
Vicky committed 2026-10-15 11:44:26 +08:00
1 parent a4aa749cc6
commit b3179fc836
251 files changed
+7261 -767

No files matched your search

@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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> {
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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, "当前网络请求过于频繁");
}
}
}
}
@@ -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;
}
}
@@ -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;
}
}
@@ -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());
}
}
@@ -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;
}