feat(backend): 补齐 C 端商业化与账号缺口(金币充值/会员/搜索/语言筛选/Google登录/短信/商店可配置接入)

This commit is contained in:
Zhou Weidong committed 2026-10-20 04:20:42 +08:00
1 parent 212834d59d
commit 63b086998e
25 files changed
+859 -29

No files matched your search

@@ -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 = "";
}
@@ -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) {
@@ -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,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);
}
}