Compare commits

...

13 Commits

Author SHA1 Message Date
e657a22b10 refactor(googleplay): 新增并发发货防护并优化订单状态流转 2026-04-10 17:39:41 +08:00
d654777a02 refactor(googleplay): 支持purchaseOptionId作为备选商品ID 2026-04-10 14:30:53 +08:00
c8f8311cae fix(core): 过滤文本括号并升级语音模型至 flash 版本 2026-04-09 16:48:47 +08:00
20f8d9c152 refactor(service): 重构用户注册逻辑并新增注销校验 2026-04-09 15:23:18 +08:00
52727dfd7c refactor(core): 统一使用产品名称替换产品ID
将 ApplePurchaseServiceImpl、GooglePlayEntitlementApplier 与 GooglePlayWalletBenefitService 中记录的 productId 改为 productName,保持日志与业务语义一致。
2026-04-09 14:04:26 +08:00
9b4819900d refactor(service): 简化交易备注中的固定前缀文案 2026-04-09 13:49:56 +08:00
cdfeace2f1 refactor(service): 重构Apple购买服务并新增一次性购买处理 2026-04-09 11:29:23 +08:00
3665596c1f refactor(core): 重构用户与购买记录逻辑并添加 UUID 字段 2026-04-09 10:42:48 +08:00
06e7828b85 refactor(core): 迁移开场白字段至i18n表并简化实体 2026-04-08 17:53:39 +08:00
b83957e0bc refactor(core): 重构Google Play订阅与商品接口逻辑 2026-04-08 17:33:36 +08:00
da3ee94924 refactor(product): 新增平台字段区分安卓与苹果商品
- 在商品实体、VO、Service及Controller中统一增加platform字段
- 查询接口支持按平台(android/apple)过滤商品
- ChatService追加全局companionSystemPrompt配置读取
2026-04-08 09:29:46 +08:00
e027918387 chore(config): 关闭生产环境混淆账号ID校验 2026-04-07 10:08:54 +08:00
02dd37ffaf 修改个人键盘多语言兜底策略 2026-04-03 16:43:43 +08:00
40 changed files with 531 additions and 104 deletions

View File

@@ -85,6 +85,7 @@ public enum ErrorCode {
REPORT_TYPE_INVALID(40020, "举报类型无效"),
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天暂不允许注册"),
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
/**

View File

@@ -26,6 +26,8 @@ public class AppConfig {
private LLmModel llmModel = new LLmModel();
@Data
public static class UserRegisterProperties {
@@ -64,6 +66,8 @@ public class AppConfig {
//聊天消息最大长度
private Integer maxMessageLength = 1000;
private String companionSystemPrompt = "";
}
@Data

View File

@@ -89,7 +89,8 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/google-play/rtdn",
"/appVersions/checkUpdate",
"/appVersions/checkUpdate",
"/character/detailWithNotLogin"
"/character/detailWithNotLogin",
"/apple/validate-receipt"
};
}
@Bean

View File

@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
import com.yolo.keyborad.service.AppleReceiptService;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -23,11 +24,14 @@ public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService;
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService) {
ApplePurchaseService applePurchaseService,
KeyboardUserPurchaseRecordsService purchaseRecordsService) {
this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService;
this.purchaseRecordsService = purchaseRecordsService;
}
@PostMapping("/receipt")
@@ -85,4 +89,26 @@ public class AppleReceiptController {
return ResultUtils.success(Boolean.TRUE);
}
/**
* 检查购买记录是否存在
* 根据 transactionId 和 originalTransactionId 查询购买记录
*
* @param body 请求体,包含 transactionId 和 originalTransactionId
* @return 存在返回 true不存在返回 false
*/
@PostMapping("/check-purchase")
public BaseResponse<Boolean> checkPurchaseExists(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String transactionId = body.get("transactionId");
String originalTransactionId = body.get("originalTransactionId");
if ((transactionId == null || transactionId.isBlank())
&& (originalTransactionId == null || originalTransactionId.isBlank())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "transactionId 和 originalTransactionId 不能同时为空");
}
boolean exists = purchaseRecordsService.checkPurchaseExists(transactionId, originalTransactionId);
return ResultUtils.success(exists);
}
}

View File

@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@@ -31,14 +32,14 @@ public class ProductsController {
private KeyboardProductItemsService productItemsService;
@GetMapping("/detail")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情通过platform区分平台")
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
@RequestParam(value = "id", required = false) Long id,
@RequestParam(value = "productId", required = false) String productId
) {
@RequestParam(value = "productId", required = false) String productId) {
if (id == null && (productId == null || productId.isBlank())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
}
// 判断平台如果是android返回安卓商品否则默认返回苹果商品
KeyboardProductItemRespVO result = (id != null)
? productItemsService.getProductDetailById(id)
: productItemsService.getProductDetailByProductId(productId);
@@ -47,25 +48,29 @@ public class ProductsController {
@GetMapping("/listByType")
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表type=all 返回全部")
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(@RequestParam("type") String type) {
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(
@RequestParam("type") String type,
@RequestHeader(value = "platform", required = false) String platform) {
if (type == null || type.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
}
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type, platform);
return ResultUtils.success(result);
}
@GetMapping("/inApp/list")
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase");
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases(
@RequestHeader(value = "platform", required = false) String platform) {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase", platform);
return ResultUtils.success(result);
}
@GetMapping("/subscription/list")
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions(
@RequestHeader(value = "platform", required = false) String platform) {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription", platform);
return ResultUtils.success(result);
}
}

View File

@@ -115,6 +115,7 @@ public class GooglePlayApiClient {
.packageName(packageName)
.productId(text(lineItem, "productId"))
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
.basePlanId(text(lineItem.path("offerDetails"), "basePlanId"))
.purchaseToken(purchaseToken)
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
.googleOrderId(googleOrderId)
@@ -145,7 +146,7 @@ public class GooglePlayApiClient {
JsonNode offerDetails = firstLineItem.path("productOfferDetails");
String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState"));
String purchaseOptionId = text(offerDetails, "purchaseOptionId");
// 修正:一次性购买的订单号字段名为 "orderId"
String googleOrderId = text(root, "orderId");
@@ -155,6 +156,7 @@ public class GooglePlayApiClient {
.productId(text(firstLineItem, "productId"))
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
.purchaseToken(purchaseToken)
.purchaseOptionId(purchaseOptionId)
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
.googleOrderId(googleOrderId)
.linkedPurchaseToken(null)

View File

@@ -25,6 +25,7 @@ public final class GooglePlayConstants {
public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE";
public static final String DELIVERY_PENDING = "PENDING";
public static final String DELIVERY_PROCESSING = "PROCESSING";
public static final String DELIVERY_DELIVERED = "DELIVERED";
public static final String DELIVERY_REVOKED = "REVOKED";
public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED";

View File

@@ -22,6 +22,7 @@ public class GooglePlayEntitlementApplier {
private final GooglePlayUserEntitlementMapper entitlementMapper;
private final GooglePlayVipBenefitService vipBenefitService;
private final GooglePlayWalletBenefitService walletBenefitService;
private final GooglePlayOrderDeliveryGuard orderDeliveryGuard;
public GooglePlayUserEntitlement apply(Long userId,
KeyboardProductItems product,
@@ -29,6 +30,7 @@ public class GooglePlayEntitlementApplier {
GooglePlayOrder order) {
String benefitType = resolveBenefitType(product, snapshot);
String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId());
boolean grantOwned = orderDeliveryGuard.prepareGrant(benefitType, snapshot, order);
GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey);
if (entitlement == null) {
entitlement = new GooglePlayUserEntitlement();
@@ -37,9 +39,11 @@ public class GooglePlayEntitlementApplier {
fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey);
switch (benefitType) {
case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement);
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME -> applyOneTimeVip(userId, product, snapshot, order, entitlement);
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP -> applyWalletTopUp(userId, product, snapshot, order, entitlement);
default -> applyNonConsumable(snapshot, order, entitlement);
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME ->
applyOneTimeVip(userId, product, snapshot, order, entitlement, grantOwned);
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP ->
applyWalletTopUp(userId, product, snapshot, order, entitlement, grantOwned);
default -> applyNonConsumable(snapshot, order, entitlement, grantOwned);
}
saveEntitlement(entitlement);
return entitlement;
@@ -94,9 +98,10 @@ public class GooglePlayEntitlementApplier {
KeyboardProductItems product,
GooglePlayPurchaseSnapshot snapshot,
GooglePlayOrder order,
GooglePlayUserEntitlement entitlement) {
GooglePlayUserEntitlement entitlement,
boolean grantOwned) {
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
grantOneTimeVip(userId, product, order, entitlement);
grantOneTimeVip(userId, product, order, entitlement, grantOwned);
return;
}
revokeVipEntitlement(userId, order, entitlement);
@@ -106,13 +111,14 @@ public class GooglePlayEntitlementApplier {
KeyboardProductItems product,
GooglePlayPurchaseSnapshot snapshot,
GooglePlayOrder order,
GooglePlayUserEntitlement entitlement) {
GooglePlayUserEntitlement entitlement,
boolean grantOwned) {
BigDecimal amount = resolveWalletAmount(product);
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
}
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
grantWalletTopUp(userId, product, order, entitlement, amount);
grantWalletTopUp(userId, product, order, entitlement, amount, grantOwned);
return;
}
revokeWalletTopUp(userId, order, entitlement, amount);
@@ -120,9 +126,14 @@ public class GooglePlayEntitlementApplier {
private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot,
GooglePlayOrder order,
GooglePlayUserEntitlement entitlement) {
GooglePlayUserEntitlement entitlement,
boolean grantOwned) {
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
if (!grantOwned && active) {
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
return;
}
entitlement.setActive(active);
entitlement.setStartTime(snapshot.getStartTime());
entitlement.setEndTime(snapshot.getExpiryTime());
@@ -139,9 +150,10 @@ public class GooglePlayEntitlementApplier {
private void grantOneTimeVip(Long userId,
KeyboardProductItems product,
GooglePlayOrder order,
GooglePlayUserEntitlement entitlement) {
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
entitlement.setActive(true);
GooglePlayUserEntitlement entitlement,
boolean grantOwned) {
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
return;
}
Date expiry = resolveOneTimeVipExpiry(product);
@@ -172,12 +184,13 @@ public class GooglePlayEntitlementApplier {
KeyboardProductItems product,
GooglePlayOrder order,
GooglePlayUserEntitlement entitlement,
BigDecimal amount) {
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
entitlement.setActive(true);
BigDecimal amount,
boolean grantOwned) {
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
return;
}
walletBenefitService.grant(userId, order.getId(), product.getProductId(), amount);
walletBenefitService.grant(userId, order.getId(), product.getName(), amount);
entitlement.setActive(true);
entitlement.setQuantity(amount);
entitlement.setStartTime(new Date());

View File

@@ -0,0 +1,67 @@
package com.yolo.keyborad.googleplay;
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
import com.yolo.keyborad.mapper.GooglePlayOrderMapper;
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.util.Date;
@Component
@RequiredArgsConstructor
public class GooglePlayOrderDeliveryGuard {
private final GooglePlayOrderMapper orderMapper;
/**
* 一次性商品在真正发权益前先抢占处理资格,避免并发请求重复发货。
*/
public boolean prepareGrant(String benefitType, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) {
if (!requiresGrantGuard(benefitType, snapshot) || order.getId() == null) {
order.setDeliveryOwnershipGranted(true);
return true;
}
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
order.setDeliveryOwnershipGranted(false);
return false;
}
Date now = new Date();
int updated = orderMapper.updateDeliveryStatusIfMatch(
order.getId(),
GooglePlayConstants.DELIVERY_PENDING,
GooglePlayConstants.DELIVERY_PROCESSING,
now
);
if (updated == 1) {
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_PROCESSING);
order.setDeliveryOwnershipGranted(true);
order.setUpdatedAt(now);
return true;
}
refreshDeliveryState(order);
order.setDeliveryOwnershipGranted(false);
return false;
}
private boolean requiresGrantGuard(String benefitType, GooglePlayPurchaseSnapshot snapshot) {
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
return false;
}
if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) {
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
}
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
}
private void refreshDeliveryState(GooglePlayOrder order) {
GooglePlayOrder latestOrder = orderMapper.selectById(order.getId());
if (latestOrder == null) {
return;
}
order.setDeliveryStatus(latestOrder.getDeliveryStatus());
order.setGrantedQuantity(latestOrder.getGrantedQuantity());
order.setUpdatedAt(latestOrder.getUpdatedAt());
}
}

View File

@@ -17,7 +17,7 @@ public class GooglePlayPubSubAuthService {
private final GooglePlayApiClient apiClient;
public void verify(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) {
verifyTopic(request);
// verifyTopic(request);
verifySubscription(pushRequest);
if (!properties.isValidatePubsubJwt()) {
return;
@@ -34,7 +34,7 @@ public class GooglePlayPubSubAuthService {
if (expectedTopic == null || expectedTopic.isBlank()) {
return;
}
String currentTopic = request.getHeader("X-Goog-Topic");
String currentTopic = request.getHeader("projects/keyboard-490601/topics/keyboard_topic");
if (!expectedTopic.equals(currentTopic)) {
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub topic 不匹配");
}

View File

@@ -15,6 +15,7 @@ import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
import com.yolo.keyborad.service.KeyboardProductItemsService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@@ -32,11 +33,13 @@ public class GooglePlayStateService {
@Transactional(rollbackFor = Exception.class)
public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
KeyboardProductItems product = loadProduct(snapshot.getProductId());
String productId = (snapshot.getBasePlanId() != null) ?
snapshot.getBasePlanId() :
snapshot.getPurchaseOptionId();
KeyboardProductItems product = loadProduct(productId);
GooglePlayOrder order = buildOrder(command, snapshot);
GooglePlayPurchaseToken token = buildToken(command, snapshot);
// 先保存订单以确保 order.id 已生成,钱包充值等权益分发依赖 order.id 写入交易流水
saveOrder(order);
persistOrderIfNew(order);
GooglePlayUserEntitlement entitlement = null;
if (command.getUserId() != null) {
entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order);
@@ -51,8 +54,8 @@ public class GooglePlayStateService {
.order(order)
.token(token)
.entitlement(entitlement)
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId()))
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId()))
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId(), order))
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId(), order))
.linkedPurchaseTokenToSync(resolveLinkedToken(snapshot))
.build();
}
@@ -144,15 +147,22 @@ public class GooglePlayStateService {
return token;
}
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId) {
return userId != null
&& GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
&& GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId, GooglePlayOrder order) {
if (userId == null
|| !GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
|| !GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
return false;
}
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
return true;
}
return Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
}
private boolean requiresConsume(GooglePlayPurchaseSnapshot snapshot,
GooglePlayUserEntitlement entitlement,
Long userId) {
Long userId,
GooglePlayOrder order) {
if (userId == null || entitlement == null) {
return false;
}
@@ -160,7 +170,8 @@ public class GooglePlayStateService {
boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType());
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState());
return oneTime && wallet && active && pending;
boolean owned = Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
return oneTime && wallet && active && pending && owned;
}
private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) {
@@ -172,20 +183,67 @@ public class GooglePlayStateService {
private void saveOrder(GooglePlayOrder order) {
if (order.getId() == null) {
orderMapper.insert(order);
insertOrder(order);
return;
}
orderMapper.updateById(order);
}
/**
* 仅在新订单场景预落库,避免并发请求用旧快照把发货状态回写成 PENDING。
*/
private void persistOrderIfNew(GooglePlayOrder order) {
if (order.getId() == null) {
saveOrder(order);
}
}
private void saveToken(GooglePlayPurchaseToken token) {
if (token.getId() == null) {
purchaseTokenMapper.insert(token);
insertToken(token);
return;
}
purchaseTokenMapper.updateById(token);
}
/**
* 并发首写同一订单时,唯一键冲突后回读已存在记录继续流程。
*/
private void insertOrder(GooglePlayOrder order) {
try {
orderMapper.insert(order);
} catch (DuplicateKeyException e) {
GooglePlayOrder existingOrder = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
.eq(GooglePlayOrder::getOrderKey, order.getOrderKey())
.last("LIMIT 1"));
if (existingOrder == null) {
throw e;
}
order.setId(existingOrder.getId());
order.setCreatedAt(existingOrder.getCreatedAt());
order.setDeliveryStatus(existingOrder.getDeliveryStatus());
order.setGrantedQuantity(existingOrder.getGrantedQuantity());
orderMapper.updateById(order);
}
}
/**
* purchaseToken 首次并发写入时回读已有记录,避免重复插入直接失败。
*/
private void insertToken(GooglePlayPurchaseToken token) {
try {
purchaseTokenMapper.insert(token);
} catch (DuplicateKeyException e) {
GooglePlayPurchaseToken existingToken = findToken(token.getPurchaseToken());
if (existingToken == null) {
throw e;
}
token.setId(existingToken.getId());
token.setCreatedAt(existingToken.getCreatedAt());
purchaseTokenMapper.updateById(token);
}
}
private void updateAckState(String purchaseToken, String orderKey, String state) {
GooglePlayPurchaseToken token = findToken(purchaseToken);
if (token != null) {

View File

@@ -20,7 +20,7 @@ public class GooglePlayWalletBenefitService {
private final KeyboardUserWalletService walletService;
private final KeyboardWalletTransactionService walletTransactionService;
public void grant(Long userId, Long orderId, String productId, BigDecimal amount) {
public void grant(Long userId, Long orderId, String productName, BigDecimal amount) {
KeyboardUserWallet wallet = getOrCreateWallet(userId);
BigDecimal before = defaultBalance(wallet.getBalance());
BigDecimal after = before.add(amount);
@@ -28,7 +28,7 @@ public class GooglePlayWalletBenefitService {
wallet.setUpdatedAt(new Date());
walletService.saveOrUpdate(wallet);
walletTransactionService.createTransaction(userId, orderId, amount, GOOGLE_PLAY_WALLET_TX_TYPE,
before, after, "Google Play 充值: " + productId);
before, after, productName);
}
public boolean revoke(Long userId, Long orderId, BigDecimal amount) {

View File

@@ -15,6 +15,8 @@ public class GooglePlayPurchaseSnapshot {
private String productType;
private String basePlanId;
private String purchaseToken;
private String orderKey;
@@ -54,4 +56,6 @@ public class GooglePlayPurchaseSnapshot {
private Date lastSyncedAt;
private String rawResponse;
private String purchaseOptionId;
}

View File

@@ -2,6 +2,23 @@ package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Update;
public interface GooglePlayOrderMapper extends BaseMapper<GooglePlayOrder> {
/**
* 原子抢占发货资格,只有当前状态匹配时才允许进入处理中。
*/
@Update("""
UPDATE google_play_order
SET delivery_status = #{targetStatus},
updated_at = #{updatedAt}
WHERE id = #{orderId}
AND delivery_status = #{expectedStatus}
""")
int updateDeliveryStatusIfMatch(@Param("orderId") Long orderId,
@Param("expectedStatus") String expectedStatus,
@Param("targetStatus") String targetStatus,
@Param("updatedAt") java.util.Date updatedAt);
}

View File

@@ -119,14 +119,6 @@ public class KeyboardAiCompanion {
@Schema(description="更新时间")
private Date updatedAt;
@TableField(value = "prologue")
@Schema(description="开场白")
private String prologue;
@TableField(value = "prologue_audio")
@Schema(description="开场白音频")
private String prologueAudio;
@TableField(value = "voice_id")
@Schema(description="角色音频Id")
private String voiceId;

View File

@@ -75,4 +75,13 @@ public class KeyboardAiCompanionI18n {
@TableField(value = "updated_at")
@Schema(description="更新时间")
private Date updatedAt;
@TableField(value = "prologue")
@Schema(description = "开场白")
private String prologue;
@TableField(value = "prologue_audio")
@Schema(description = "开场白音频")
private String prologueAudio;
}

View File

@@ -112,4 +112,8 @@ public class KeyboardProductItems {
@TableField(value = "level")
@Schema(description = "级别")
private Integer level;
@TableField(value = "platform")
@Schema(description = "所属平台")
private String platform;
}

View File

@@ -132,4 +132,8 @@ public class KeyboardUser {
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
}

View File

@@ -81,4 +81,10 @@ public class GooglePlayOrder {
@TableField("updated_at")
private Date updatedAt;
/**
* 当前线程是否拿到了本次发货资格,仅用于本次请求内控制幂等,不落库。
*/
@TableField(exist = false)
private Boolean deliveryOwnershipGranted;
}

View File

@@ -45,6 +45,9 @@ public class KeyboardProductItemRespVO {
private String description;
@Schema(description = "级别")
private Integer level;
private Integer level;
@Schema(description = "所属平台")
private String platform;
}

View File

@@ -57,4 +57,6 @@ public class KeyboardUserInfoRespVO {
@Schema(description = "vip等级")
private Integer vipLevel;
@Schema(description = "uuid")
private String uuid;
}

View File

@@ -59,4 +59,8 @@ public class KeyboardUserRespVO {
@TableField(value = "vip_level")
@Schema(description = "vip等级")
private Integer vipLevel;
@TableField(value = "uuid")
@Schema(description = "uuid")
private String uuid;
}

View File

@@ -43,4 +43,11 @@ public interface ApplePurchaseService {
* @param notification 解码后的通知载荷
*/
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
/**
* 处理一次性购买通知ONE_TIME_CHARGE
*
* @param notification 解码后的通知载荷
*/
void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification);
}

View File

@@ -12,7 +12,7 @@ import java.util.List;
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
/**
* 根据主键ID查询商品明细
* 根据主键ID和平台查询商品明细
*
* @param id 商品主键ID
* @return 商品明细(不存在返回 null
@@ -20,7 +20,7 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
KeyboardProductItemRespVO getProductDetailById(Long id);
/**
* 根据 Apple productId 查询商品明细
* 根据 productId 和平台查询商品明细
*
* @param productId 商品 productId
* @return 商品明细(不存在返回 null
@@ -39,6 +39,6 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
* @param type 商品类型subscription / in-app-purchase / all
* @return 商品列表
*/
List<KeyboardProductItemRespVO> listProductsByType(String type);
List<KeyboardProductItemRespVO> listProductsByType(String type, String platform);
}

View File

@@ -8,4 +8,13 @@ import com.baomidou.mybatisplus.extension.service.IService;
*/
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
/**
* 检查购买记录是否存在
*
* @param transactionId 交易 ID
* @param originalTransactionId 原始交易 ID
* @return 存在返回 true不存在返回 false
*/
boolean checkPurchaseExists(String transactionId, String originalTransactionId);
}

View File

@@ -38,4 +38,5 @@ public interface UserService extends IService<KeyboardUser> {
*/
Boolean cancelAccount(long userId);
Long selectUserByUUid(String uuid);
}

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.model.NotificationTypeV2;
@@ -34,6 +35,7 @@ import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
/**
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
@@ -152,23 +154,10 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
JWSTransactionDecodedPayload transaction =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String originalTransactionId = transaction.getOriginalTransactionId();
String productId = transaction.getProductId();
// 根据原始交易ID查询用户购买记录
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
.orderByDesc(KeyboardUserPurchaseRecords::getId)
.last("LIMIT 1")
.list();
if (records == null || records.isEmpty()) {
log.warn("No purchase record found for originalTransactionId={}", originalTransactionId);
return;
}
KeyboardUserPurchaseRecords existingRecord = records.get(0);
Long userId = existingRecord.getUserId().longValue();
UUID appAccountToken = transaction.getAppAccountToken();
Long userId = userService.selectUserByUUid(appAccountToken.toString());
// 查询商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
@@ -354,6 +343,127 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
}
/**
* 处理一次性购买通知ONE_TIME_CHARGE
* 流程参考 processPurchase
* 1. 验证并解码交易信息
* 2. 幂等性检查,避免重复处理
* 3. 通过 originalTransactionId 查找用户
* 4. 查询商品信息
* 5. 保存购买记录
* 6. 根据商品类型执行对应逻辑(订阅延期或钱包充值)
*
* @param notification 解码后的通知载荷
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification) {
if (notification == null || notification.getData() == null) {
log.warn("ONE_TIME_CHARGE notification data is null");
return;
}
try {
// 1. 解码交易信息
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
log.warn("No signed transaction info in ONE_TIME_CHARGE notification");
return;
}
JWSTransactionDecodedPayload transaction =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
String transactionId = transaction.getTransactionId();
String originalTransactionId = transaction.getOriginalTransactionId();
String productId = transaction.getProductId();
UUID appAccountToken = transaction.getAppAccountToken();
Long userId = userService.selectUserByUUid(appAccountToken.toString());
log.info("Processing ONE_TIME_CHARGE: transactionId={}, productId={}", transactionId, productId);
// 2. 幂等性检查根据交易ID判断是否已处理
boolean handled = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
.exists();
if (handled) {
log.info("ONE_TIME_CHARGE already handled, transactionId={}", transactionId);
return;
}
// // 3. 通过 originalTransactionId 查找关联的用户购买记录以获取 userId
// List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
// .eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
// .orderByDesc(KeyboardUserPurchaseRecords::getId)
// .last("LIMIT 1")
// .list();
//
// if (records == null || records.isEmpty()) {
// log.warn("No purchase record found for ONE_TIME_CHARGE, originalTransactionId={}", originalTransactionId);
// return;
// }
// Long userId = records.get(0).getUserId().longValue();
// 4. 查询商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
if (product == null) {
log.error("Product not found for ONE_TIME_CHARGE, productId={}", productId);
return;
}
// 5. 构建并保存购买记录
KeyboardUserPurchaseRecords purchaseRecord = new KeyboardUserPurchaseRecords();
purchaseRecord.setUserId(userId.intValue());
purchaseRecord.setProductId(productId);
purchaseRecord.setPurchaseQuantity(product.getDurationValue());
purchaseRecord.setPrice(product.getPrice());
purchaseRecord.setCurrency(product.getCurrency());
purchaseRecord.setPurchaseType(product.getType());
purchaseRecord.setStatus("PAID");
purchaseRecord.setPaymentMethod("APPLE");
purchaseRecord.setTransactionId(transactionId);
purchaseRecord.setOriginalTransactionId(originalTransactionId);
purchaseRecord.setProductIds(new String[]{productId});
if (transaction.getPurchaseDate() != null) {
purchaseRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
purchaseRecord.setPurchaseDate(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
} else {
purchaseRecord.setPurchaseTime(new Date());
}
if (transaction.getExpiresDate() != null) {
purchaseRecord.setExpiresDate(Date.from(Instant.ofEpochMilli(transaction.getExpiresDate())));
}
if (transaction.getEnvironment() != null) {
purchaseRecord.setEnvironment(transaction.getEnvironment().name());
}
purchaseRecordsService.save(purchaseRecord);
// 6. 根据商品类型执行对应的业务逻辑
if ("subscription".equalsIgnoreCase(product.getType())) {
Instant expiresInstant = transaction.getExpiresDate() != null
? Instant.ofEpochMilli(transaction.getExpiresDate()) : null;
extendVip(userId, product, expiresInstant);
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
handleInAppPurchase(userId, product, purchaseRecord.getId());
} else {
log.warn("未知商品类型, type={}, productId={}", product.getType(), productId);
}
log.info("ONE_TIME_CHARGE processed successfully: userId={}, transactionId={}", userId, transactionId);
} catch (VerificationException e) {
log.error("Failed to verify transaction in ONE_TIME_CHARGE notification", e);
} catch (Exception e) {
log.error("Error processing ONE_TIME_CHARGE notification", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理一次性购买通知失败");
}
}
/**
* 处理成功的续订
* 创建新的购买记录并延长VIP有效期
@@ -673,7 +783,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
(short) 2, // 交易类型2-苹果内购充值
before,
after,
"Apple 充值: " + product.getProductId()
product.getName()
);
// 8. 记录充值成功日志

View File

@@ -155,6 +155,7 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
// 续订偏好变更通知
case DID_CHANGE_RENEWAL_PREF:
case DID_CHANGE_RENEWAL_STATUS:
case PRICE_INCREASE:
applePurchaseService.handleRenewalPreferenceChange(notification);
@@ -165,9 +166,14 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
applePurchaseService.handleConsumptionRequest(notification);
break;
// 一次性购买通知
case ONE_TIME_CHARGE:
applePurchaseService.handleOneTimeChargeNotification(notification);
break;
// 其他通知类型(记录但不处理)
case EXTERNAL_PURCHASE_TOKEN:
case ONE_TIME_CHARGE:
case REVOKE:
case TEST:
log.info("Received notification type {} - no action required", type);

View File

@@ -413,7 +413,7 @@ public class ChatServiceImpl implements ChatService {
if (companion.getStatus() == null || companion.getStatus() != 1) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "AI陪聊角色已下线");
}
String systemPrompt = companion.getSystemPrompt();
String systemPrompt = appConfig.getLlmConfig().getCompanionSystemPrompt() + companion.getSystemPrompt();
String voiceId = companion.getVoiceId();
// 获取最近20条聊天记录作为上下文
@@ -538,7 +538,7 @@ public class ChatServiceImpl implements ChatService {
// 1. TTS 转换
long ttsStart = System.currentTimeMillis();
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text, voiceId);
TextToSpeechVO ttsResult = elevenLabsService.textToSpeechWithTimestamps(text.replaceAll("\\(.*?\\)", ""), voiceId);
long ttsDuration = System.currentTimeMillis() - ttsStart;
log.info("TTS 完成, audioId: {}, 耗时: {}ms", audioId, ttsDuration);

View File

@@ -46,7 +46,7 @@ public class GooglePlayBillingServiceImpl implements GooglePlayBillingService {
String packageName = resolvePackageName(req.getPackageName());
String productType = normalizeProductType(req.getProductType());
GooglePlayPurchaseSnapshot snapshot = fetchSnapshot(packageName, productType, req.getPurchaseToken());
validateProduct(snapshot, req.getProductId());
// validateProduct(snapshot, req.getProductId());
verifyExternalAccount(userId, snapshot);
GooglePlaySyncCommand command = GooglePlaySyncCommand.builder()
.userId(userId)
@@ -163,7 +163,7 @@ public class GooglePlayBillingServiceImpl implements GooglePlayBillingService {
if (requestProductId == null || requestProductId.isBlank()) {
return;
}
if (!requestProductId.equals(snapshot.getProductId())) {
if (!requestProductId.equals(snapshot.getBasePlanId())) {
throw new BusinessException(ErrorCode.GOOGLE_PLAY_PURCHASE_MISMATCH, "productId 与 Google 返回不一致");
}
}

View File

@@ -324,6 +324,8 @@ public class KeyboardAiCompanionServiceImpl extends ServiceImpl<KeyboardAiCompan
vo.setName(i18n.getName());
vo.setShortDesc(i18n.getShortDesc());
vo.setIntroText(i18n.getIntroText());
vo.setPrologue(i18n.getPrologue());
vo.setPrologueAudio(i18n.getPrologueAudio());
return vo;
}

View File

@@ -159,6 +159,9 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
public List<KeyboardUserCharacterVO> selectListByUserId(String acceptLanguage) {
long loginId = StpUtil.getLoginIdAsLong();
String locale = RequestLocaleUtils.resolveLanguage(acceptLanguage);
if (!StringUtils.hasText(locale)) {
locale = DEFAULT_FALLBACK_LOCALE;
}
return keyboardUserCharacterMapper.selectByUserId(loginId, locale);
}

View File

@@ -17,45 +17,41 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
/**
* 根据ID获取产品详情
*
* 根据ID和平台获取产品详情
*
* @param id 产品ID
* @return 产品详情响应对象,如果ID为空或未找到产品则返回null
* @return 产品详情响应对象如果未找到产品则返回null
*/
@Override
public KeyboardProductItemRespVO getProductDetailById(Long id) {
// 参数校验ID不能为空
if (id == null) {
return null;
}
// 根据ID查询产品信息
KeyboardProductItems item = this.getById(id);
// 将实体对象转换为响应VO对象并返回
KeyboardProductItems item = this.lambdaQuery()
.eq(KeyboardProductItems::getId, id)
.one();
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
/**
* 根据产品ID获取产品详情
*
* 根据产品ID和平台获取产品详情
*
* @param productId 产品ID
* @return 产品详情响应对象,如果产品ID为空或未找到产品则返回null
* @return 产品详情响应对象如果未找到产品则返回null
*/
@Override
public KeyboardProductItemRespVO getProductDetailByProductId(String productId) {
// 参数校验产品ID不能为空
if (productId == null || productId.isBlank()) {
return null;
}
// 根据产品ID查询产品信息
KeyboardProductItems item = this.lambdaQuery()
.eq(KeyboardProductItems::getProductId, productId)
.one();
// 将实体对象转换为响应VO对象并返回
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
@@ -77,14 +73,19 @@ public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProduct
* @return 产品详情响应列表按ID升序排列
*/
@Override
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type) {
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type, String platform) {
// 创建Lambda查询构造器
var query = this.lambdaQuery();
// 如果类型参数有效且不是"all",则添加类型过滤条件
if (type != null && !type.isBlank() && !"all".equalsIgnoreCase(type)) {
query.eq(KeyboardProductItems::getType, type);
}
// 根据平台过滤商品
if (platform != null && !platform.isBlank()) {
query.eq(KeyboardProductItems::getPlatform, platform);
}
// 执行查询按ID升序排列
java.util.List<KeyboardProductItems> items = query

View File

@@ -133,7 +133,7 @@ public class KeyboardThemePurchaseServiceImpl extends ServiceImpl<KeyboardThemeP
(short) 1, // 交易类型1-购买主题
beforeBalance, // 交易前余额
afterBalance, // 交易后余额
"购买主题: " + theme.getThemeName() // 交易备注
theme.getThemeName() // 交易备注
);
// 8. 更新购买记录的交易ID

View File

@@ -1,8 +1,7 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
@@ -11,8 +10,19 @@ import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Service
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
@Override
public boolean checkPurchaseExists(String transactionId, String originalTransactionId) {
LambdaQueryWrapper<KeyboardUserPurchaseRecords> queryWrapper = new LambdaQueryWrapper<>();
if (transactionId != null && !transactionId.isBlank()) {
queryWrapper.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId);
}
if (originalTransactionId != null && !originalTransactionId.isBlank()) {
queryWrapper.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId);
}
return exists(queryWrapper);
}
}

View File

@@ -2,6 +2,7 @@ package com.yolo.keyborad.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.core.bean.BeanUtil;
import cn.hutool.core.lang.UUID;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.RandomUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -26,6 +27,8 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import com.yolo.keyborad.service.impl.user.UserInviteCodeBinder;
import com.yolo.keyborad.service.impl.user.UserMailVerificationHandler;
@@ -89,8 +92,26 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
.eq(KeyboardUser::getStatus, false));
}
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
private void ensureSubjectIdNotRecentlyCancelled(String sub) {
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getSubjectId, sub)
.eq(KeyboardUser::getDeleted, true)
.gt(KeyboardUser::getDeletedAt, cooldownStart)
.last("LIMIT 1")
);
if (recentlyDeleted != null) {
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
}
}
@Override
public KeyboardUser createUserWithSubjectId(String sub) {
ensureSubjectIdNotRecentlyCancelled(sub);
KeyboardUser keyboardUser = buildNewUserWithSubjectId(sub);
keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
@@ -198,11 +219,20 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
return true;
}
@Override
public Long selectUserByUUid(String uuid) {
KeyboardUser keyboardUserDB = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getUuid, uuid));
return keyboardUserDB.getId();
}
private KeyboardUser buildNewUserWithSubjectId(String sub) {
KeyboardUser keyboardUser = new KeyboardUser();
keyboardUser.setSubjectId(sub);
keyboardUser.setUid(IdUtil.getSnowflake().nextId());
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser;
}

View File

@@ -18,6 +18,8 @@ import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.utils.RedisUtil;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -33,6 +35,7 @@ import org.springframework.transaction.annotation.Transactional;
public class UserRegistrationHandler {
private static final String USER_CODE_PREFIX = "user:";
private static final int ACCOUNT_REUSE_COOLDOWN_DAYS = 7;
private final KeyboardUserMapper keyboardUserMapper;
private final PasswordEncoder passwordEncoder;
@@ -91,6 +94,24 @@ public class UserRegistrationHandler {
if (userMail != null) {
throw new BusinessException(ErrorCode.USER_HAS_EXISTED);
}
ensureNotRecentlyCancelled(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, mailAddress)
);
}
private void ensureNotRecentlyCancelled(LambdaQueryWrapper<KeyboardUser> baseQuery) {
Date cooldownStart = Date.from(Instant.now().minus(ACCOUNT_REUSE_COOLDOWN_DAYS, ChronoUnit.DAYS));
KeyboardUser recentlyDeleted = keyboardUserMapper.selectOne(
baseQuery
.eq(KeyboardUser::getDeleted, true)
.gt(KeyboardUser::getDeletedAt, cooldownStart)
.last("LIMIT 1")
);
if (recentlyDeleted != null) {
throw new BusinessException(ErrorCode.ACCOUNT_RECENTLY_CANCELLED);
}
}
private void validatePasswords(UserRegisterDTO userRegisterDTO) {
@@ -118,6 +139,7 @@ public class UserRegistrationHandler {
keyboardUser.setEmail(userRegisterDTO.getMailAddress());
keyboardUser.setGender(userRegisterDTO.getGender());
keyboardUser.setEmailVerified(true);
keyboardUser.setUuid(IdUtil.randomUUID());
return keyboardUser;
}

View File

@@ -119,7 +119,7 @@ nacos:
elevenlabs:
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
model-id: eleven_flash_v2_5
output-format: mp3_44100_128
deepgram:

View File

@@ -59,7 +59,7 @@ google:
android-publisher-scope: "https://www.googleapis.com/auth/androidpublisher"
pubsub-token-info-uri: "https://oauth2.googleapis.com/tokeninfo"
validate-pubsub-jwt: true
require-obfuscated-account-id: true
require-obfuscated-account-id: false
pubsub:
expected-topic: "projects/keyboard-490601/topics/keyboard_topic"
expected-subscription: "projects/keyboard-490601/subscriptions/keyboard_topic-sub"
@@ -91,7 +91,7 @@ sa-token:
elevenlabs:
api-key: sk_25339d32bb14c91f460ed9fce83a1951672f07846a7a10ce
voice-id: JBFqnCBsd6RMkjVDRZzb
model-id: eleven_turbo_v2_5
model-id: eleven_flash_v2_5
output-format: mp3_44100_128
deepgram:

View File

@@ -9,7 +9,7 @@
SELECT
kuc.id,
kuc.character_id,
kci.character_name,
COALESCE(kci.character_name, kci_en.character_name) AS character_name,
kuc.emoji
FROM keyboard_user_character kuc
JOIN keyboard_user_sort kus
@@ -17,6 +17,9 @@
LEFT JOIN keyboard_character_i18n kci
ON kuc.character_id = kci.character_id
AND kci."locale" = #{locale}
LEFT JOIN keyboard_character_i18n kci_en
ON kuc.character_id = kci_en.character_id
AND kci_en."locale" = 'en'
WHERE kuc.user_id = #{loginId}
AND kuc.deleted = FALSE
ORDER BY array_position(kus.user_characteu_id_sort, kuc.id) NULLS LAST;