feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)
This commit is contained in:
1 parent
212834d59d
commit
63b086998e
25 files changed
+859
-29
No files matched your search
+25
@@ -0,0 +1,25 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* Google 登录配置。
|
||||
*
|
||||
* <p>凭据只从配置/环境变量读,不入库、不提交到仓库:
|
||||
* <pre>
|
||||
* fruitshorts.auth.google.client-id: 1234567890-xxx.apps.googleusercontent.com
|
||||
* </pre>
|
||||
* 未配置 clientId 时,Google 登录接口明确返回"未配置"错误,不会静默放行。
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.auth.google")
|
||||
public class GoogleAuthProperties {
|
||||
|
||||
/** Google OAuth 客户端 id,用于校验 idToken 的 audience。 */
|
||||
private String clientId = "";
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 短信发送配置。
|
||||
*
|
||||
* <p>默认关闭:未配置时验证码走日志通道({@code LogOtpSender})。开启后按通用 webhook 发送:
|
||||
* <pre>
|
||||
* fruitshorts.auth.sms.enabled: true
|
||||
* fruitshorts.auth.sms.api-url: https://sms.example.com/send
|
||||
* fruitshorts.auth.sms.api-key: xxx
|
||||
* </pre>
|
||||
*/
|
||||
@Data
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "fruitshorts.auth.sms")
|
||||
public class SmsProperties {
|
||||
|
||||
/** 是否启用短信通道;false 时继续走日志。 */
|
||||
private boolean enabled = false;
|
||||
|
||||
/** 短信发送 webhook 地址;为空时降级为只写日志。 */
|
||||
private String apiUrl = "";
|
||||
|
||||
/** 短信接口鉴权 key,随 Authorization 头下发;可为空。 */
|
||||
private String apiKey = "";
|
||||
}
|
||||
+112
@@ -35,6 +35,7 @@ import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/**
|
||||
@@ -61,6 +62,7 @@ public class AppAuthService {
|
||||
private final JwtTokenProvider tokenProvider;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final ConfigService configService;
|
||||
private final GoogleIdTokenVerifier googleVerifier;
|
||||
|
||||
/** 后台 sys_config 缺失或非法时使用;小于等于 0 表示不限制。 */
|
||||
@Value("${fruitshorts.auth.register-max-per-ip-per-day:5}")
|
||||
@@ -179,6 +181,116 @@ public class AppAuthService {
|
||||
req.getAppVersion(), req.getPlatform(), clientIp);
|
||||
}
|
||||
|
||||
/**
|
||||
* Google 登录:校验 idToken,按邮箱找到或自动创建账号,签发令牌。
|
||||
*
|
||||
* <p>复用邮箱/密码体系:首次登录先落一条 EMAIL 凭证(verified=1,Google 已验证邮箱),
|
||||
* 再落一条 GOOGLE 凭证用于下次直接登录;已有邮箱账号时只补 GOOGLE 凭证,不重复建号。
|
||||
*/
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO googleLogin(String idToken) {
|
||||
GoogleUser googleUser = googleVerifier.verify(idToken);
|
||||
String email = requireEmail(googleUser);
|
||||
AppUserCredential googleCredential = findCredential(CredentialType.GOOGLE.name(), email);
|
||||
boolean newUser = false;
|
||||
Long userId;
|
||||
if (googleCredential == null) {
|
||||
AppUserCredential emailCredential = findCredential(CredentialType.EMAIL.name(), email);
|
||||
if (emailCredential == null) {
|
||||
AppUser user = createGoogleUser(googleUser);
|
||||
insertCredential(user.getId(), CredentialType.EMAIL.name(), email, "");
|
||||
userId = user.getId();
|
||||
newUser = true;
|
||||
} else {
|
||||
userId = emailCredential.getUserId();
|
||||
}
|
||||
googleCredential = insertCredential(userId, CredentialType.GOOGLE.name(), email,
|
||||
googleUser.sub());
|
||||
} else {
|
||||
userId = googleCredential.getUserId();
|
||||
}
|
||||
AppUser user = requireActiveUser(userId);
|
||||
googleCredential.setLastUsedAt(LocalDateTime.now());
|
||||
credentialMapper.updateById(googleCredential);
|
||||
user.setLastLoginAt(LocalDateTime.now());
|
||||
user.setLastLoginIp("");
|
||||
appUserMapper.updateById(user);
|
||||
TokenRespVO resp = issueTokens(user, googleCredential, null, null, null, null, null, null);
|
||||
resp.setNewUser(newUser);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** Google 账号必须带邮箱,否则没有可绑定的登录标识。 */
|
||||
private String requireEmail(GoogleUser googleUser) {
|
||||
String email = googleUser.email() == null ? "" : googleUser.email().trim().toLowerCase(Locale.ROOT);
|
||||
if (!StringUtils.hasText(email)) {
|
||||
throw new ServiceException(ErrorCode.BAD_REQUEST, "Google 账号未返回邮箱,无法登录");
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/** 创建 Google 用户:邮箱已验证,昵称与头像直接取 Google 返回。 */
|
||||
private AppUser createGoogleUser(GoogleUser googleUser) {
|
||||
AppUser user = new AppUser();
|
||||
user.setUid(hasher.newUid());
|
||||
String nickname = StringUtils.hasText(googleUser.name())
|
||||
? googleUser.name() : "User" + user.getUid().substring(1, 7);
|
||||
user.setNickname(nickname);
|
||||
user.setAvatarUrl(AppIdentifier.nullSafe(googleUser.picture()));
|
||||
user.setGender("");
|
||||
user.setCountryCode("ID");
|
||||
user.setRegionCode("ID");
|
||||
user.setLanguage("id-ID");
|
||||
user.setTimezone("Asia/Jakarta");
|
||||
user.setCurrency("IDR");
|
||||
user.setStatus(AppUserStatus.NORMAL);
|
||||
user.setRegisterSource("APP");
|
||||
user.setRegisterChannel("");
|
||||
user.setRegisterDeviceId("");
|
||||
user.setRegisterIp("");
|
||||
user.setAgeVerified(0);
|
||||
user.setAgreementVersion("");
|
||||
user.setAgreementAt(LocalDateTime.now());
|
||||
user.setVipLevel(0);
|
||||
user.setRiskLevel(0);
|
||||
user.setInviteCode("");
|
||||
user.setReferrerUserId(0L);
|
||||
appUserMapper.insert(user);
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 插入一条已验证凭证;并发重复由唯一索引兜底。 */
|
||||
private AppUserCredential insertCredential(Long userId, String type, String identifier,
|
||||
String federationId) {
|
||||
AppUserCredential credential = new AppUserCredential();
|
||||
credential.setUserId(userId);
|
||||
credential.setType(type);
|
||||
credential.setIdentifier(identifier);
|
||||
credential.setFederationId(AppIdentifier.nullSafe(federationId));
|
||||
credential.setAppId("");
|
||||
credential.setPasswordHash("");
|
||||
credential.setVerified(1);
|
||||
credential.setVerifiedAt(LocalDateTime.now());
|
||||
credential.setLastUsedAt(LocalDateTime.now());
|
||||
try {
|
||||
credentialMapper.insert(credential);
|
||||
} catch (DuplicateKeyException ex) {
|
||||
throw new ServiceException(ErrorCode.DATA_EXISTS, "账号已存在,请直接登录");
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
private AppUser requireActiveUser(Long userId) {
|
||||
AppUser user = appUserMapper.selectById(userId);
|
||||
if (user == null) {
|
||||
throw new ServiceException(ErrorCode.LOGIN_FAILED);
|
||||
}
|
||||
if (AppUserStatus.isBlocked(user.getStatus())) {
|
||||
throw new ServiceException(ErrorCode.ACCOUNT_DISABLED);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
/** 刷新令牌:轮换 refresh token,旧的立即失效。 */
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public TokenRespVO refresh(String refreshToken) {
|
||||
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.common.exception.ErrorCode;
|
||||
import com.fruitshorts.common.exception.ServiceException;
|
||||
import com.fruitshorts.user.config.GoogleAuthProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/**
|
||||
* Google idToken 校验器。
|
||||
*
|
||||
* <p>用 Google 官方 tokeninfo 接口离线校验,不引入 Google SDK:
|
||||
* 拿到返回后比对 audience(aud)是否等于配置的 clientId,防止拿别人应用的 token 登录。
|
||||
* 未配置 clientId 时直接报"未配置",避免无凭据环境下静默失败。
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GoogleIdTokenVerifier {
|
||||
|
||||
/** Google tokeninfo 校验地址。 */
|
||||
private static final String TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo?id_token=";
|
||||
/** 调用 Google 接口的超时(秒)。 */
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private final GoogleAuthProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.build();
|
||||
|
||||
/**
|
||||
* 校验 idToken 并返回 Google 用户信息。
|
||||
*
|
||||
* @param idToken 客户端从 Google 拿到的 idToken
|
||||
*/
|
||||
public GoogleUser verify(String idToken) {
|
||||
String clientId = properties.getClientId();
|
||||
if (!StringUtils.hasText(clientId)) {
|
||||
throw new ServiceException(ErrorCode.OPERATION_NOT_ALLOWED, "Google 登录未配置");
|
||||
}
|
||||
if (!StringUtils.hasText(idToken)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
JsonNode body = fetchTokenInfo(idToken);
|
||||
String aud = body.path("aud").asText("");
|
||||
String sub = body.path("sub").asText("");
|
||||
if (!clientId.equals(aud) || !StringUtils.hasText(sub)) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
return new GoogleUser(sub, body.path("email").asText(""),
|
||||
body.path("name").asText(""), body.path("picture").asText(""));
|
||||
}
|
||||
|
||||
/** 调 tokeninfo 拿原始返回;网络异常或非 2xx 一律按"令牌无效"处理。 */
|
||||
private JsonNode fetchTokenInfo(String idToken) {
|
||||
String url = TOKENINFO_URL + URLEncoder.encode(idToken, StandardCharsets.UTF_8);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.GET()
|
||||
.build();
|
||||
try {
|
||||
HttpResponse<String> response = httpClient.send(request,
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
return objectMapper.readTree(response.body());
|
||||
} catch (ServiceException ex) {
|
||||
throw ex;
|
||||
} catch (IOException ex) {
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new ServiceException(ErrorCode.UNAUTHORIZED, "Google 令牌无效");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
/**
|
||||
* Google 令牌校验通过后取到的用户信息。
|
||||
*
|
||||
* @param sub Google 用户唯一标识(subject)
|
||||
* @param email 邮箱,可能为空(个别 Google 账号未绑定邮箱)
|
||||
* @param name 显示名,可能为空
|
||||
* @param picture 头像地址,可能为空
|
||||
*/
|
||||
public record GoogleUser(String sub, String email, String name, String picture) {
|
||||
}
|
||||
+3
-2
@@ -3,16 +3,17 @@
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 开发用发送器:只打日志,不真正发短信。
|
||||
* 生产环境把 fruitshorts.otp.sender 配成 sms / email 并实现对应的 OtpSender。
|
||||
* 同时要求短信通道未启用(fruitshorts.auth.sms.enabled != true),避免与 {@link SmsOtpSender} 同时装配。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@ConditionalOnProperty(name = "fruitshorts.otp.sender", havingValue = "log", matchIfMissing = true)
|
||||
@ConditionalOnExpression("'${fruitshorts.otp.sender:log}' == 'log' && '${fruitshorts.auth.sms.enabled:false}' != 'true'")
|
||||
public class LogOtpSender implements OtpSender {
|
||||
|
||||
@Override
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Author: fruit.com.cn
|
||||
|
||||
package com.fruitshorts.user.service;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fruitshorts.user.config.SmsProperties;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URI;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 短信验证码发送器:只有 {@code fruitshorts.auth.sms.enabled=true} 时才装配,默认由 {@link LogOtpSender} 生效。
|
||||
*
|
||||
* <p>为通用起见,走一个 webhook 风格接口:POST JSON {phone, code, scene},带 Authorization 头。
|
||||
* 只处理 PHONE 类型,EMAIL 仍走日志;未配置 api-url 时降级为只写日志,方便开发调试。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
@ConditionalOnProperty(name = "fruitshorts.auth.sms.enabled", havingValue = "true")
|
||||
public class SmsOtpSender implements OtpSender {
|
||||
|
||||
/** 只处理手机号渠道。 */
|
||||
private static final String PHONE_TYPE = "PHONE";
|
||||
/** 短信接口超时(秒)。 */
|
||||
private static final int TIMEOUT_SECONDS = 5;
|
||||
|
||||
private final SmsProperties properties;
|
||||
private final ObjectMapper objectMapper = new ObjectMapper();
|
||||
private final HttpClient httpClient = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.build();
|
||||
|
||||
@Override
|
||||
public void send(String targetType, String target, String scene, String code) {
|
||||
if (!PHONE_TYPE.equals(targetType)) {
|
||||
log.info("[OTP] 场景={} 渠道={} 接收方={} 验证码={}(邮箱走日志通道)",
|
||||
scene, targetType, mask(target), code);
|
||||
return;
|
||||
}
|
||||
if (!StringUtils.hasText(properties.getApiUrl())) {
|
||||
log.info("[OTP] 场景={} 接收方={} 验证码={}(未配置短信接口,仅日志)",
|
||||
scene, mask(target), code);
|
||||
return;
|
||||
}
|
||||
postSms(target, scene, code);
|
||||
}
|
||||
|
||||
/** 调短信 webhook;失败只记日志,不打断验证码流程(验证码已入库,可重试)。 */
|
||||
private void postSms(String phone, String scene, String code) {
|
||||
Map<String, String> payload = new LinkedHashMap<>();
|
||||
payload.put("phone", phone);
|
||||
payload.put("code", code);
|
||||
payload.put("scene", scene);
|
||||
try {
|
||||
String json = objectMapper.writeValueAsString(payload);
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(properties.getApiUrl()))
|
||||
.timeout(Duration.ofSeconds(TIMEOUT_SECONDS))
|
||||
.header("Content-Type", "application/json");
|
||||
if (StringUtils.hasText(properties.getApiKey())) {
|
||||
builder.header("Authorization", "Bearer " + properties.getApiKey());
|
||||
}
|
||||
HttpResponse<String> response = httpClient.send(
|
||||
builder.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
|
||||
.build(),
|
||||
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
log.warn("[OTP] 短信接口返回 HTTP {}:{}", response.statusCode(), response.body());
|
||||
}
|
||||
} catch (IOException ex) {
|
||||
log.warn("[OTP] 短信发送失败:{}", ex.getMessage());
|
||||
} catch (InterruptedException ex) {
|
||||
Thread.currentThread().interrupt();
|
||||
log.warn("[OTP] 短信发送被中断");
|
||||
}
|
||||
}
|
||||
|
||||
private String mask(String target) {
|
||||
if (target == null || target.length() <= 6) {
|
||||
return "****";
|
||||
}
|
||||
return target.substring(0, 3) + "****" + target.substring(target.length() - 3);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user