Compare commits

...

23 Commits

Author SHA1 Message Date
0ad9de1011 fix(controller): 使用官方 SDK 验证 Apple 交易签名并解析 payload 2025-12-15 18:22:11 +08:00
d9a778f5aa refactor(apple-purchase): 重构苹果购买服务,增强可读性和健壮性 2025-12-15 15:15:10 +08:00
a70c1f4049 feat(apple): 新增服务器通知续订与JWT解析能力
- 支持解析Apple签名JWT并提取交易信息
- 新增processRenewNotification处理续订通知
- 添加测试用JWT生成、解析及发送重试记录示例
- 移除废弃ApplePayUtil,统一走新验证逻辑
2025-12-15 14:56:38 +08:00
c1dd4faf0e feat(apple): 新增苹果订阅通知接口并补充测试
- AppleReceiptController 新增 /apple/notification 端点,用于接收苹果服务器通知
- 调整路径前缀为 /apple,开放 /apple/receipt 与 /apple/notification 免登录
2025-12-12 20:37:43 +08:00
a24a795887 feat(purchase): 新增 Apple 内购完整链路
- AppleReceiptController 改造:验签后立刻落库并解锁权益
- 新增 ApplePurchaseService 处理业务:防重、写订单、发道具
- 新增 KeyboardUserPurchaseRecords 实体与 Mapper,记录用户购买
- ErrorCode 补充 RECEIPT_INVALID(50016)
- 删除过期 AGENTS.md,修正 i18n_message 表名与 CORS 白名单
2025-12-12 18:18:55 +08:00
2e16183cb8 feat(product): 新增键盘商品管理模块
新增商品实体、Mapper、Service、Controller 及 VO,支持商品列表、详情、订阅等接口;同步更新 Sa-Token 放行路径与 .gitignore
2025-12-12 14:15:30 +08:00
b4c35b0df3 fix(service): 优化向量搜索超时与中断处理
新增 ListenableFuture 超时保护(10s),捕获中断与超时异常并恢复中断状态,提升高并发鲁棒性。
2025-12-12 13:24:03 +08:00
f391f9dfe1 feat(user): 新增VIP字段及完善MyBatis-Plus映射 2025-12-11 20:51:34 +08:00
07ff9a5ff2 feat(login): 新增用户登录日志记录功能
新增 KeyboardUserLoginLog 实体、Mapper、Service 及 XML,扩展 Apple 与普通登录接口,自动记录 IP、UA、平台、OS 及新用户标识。
2025-12-11 20:16:20 +08:00
071e130a45 feat(service): 用户使用苹果注册时自动初始化钱包 2025-12-11 20:10:08 +08:00
360ac7a885 fix(auth): 添加用户主题批量删除接口放行
feat(user): 注册用户时初始化钱包
2025-12-11 19:50:19 +08:00
5121bf3455 fix(service): 修正推荐主题查询逻辑避免重复数据
优化 getRecommendedThemes 方法,使用 limit(8) 替代 SQL 的 LIMIT 8,防止分页插件干扰,确保只返回 8 条未购买的热门主题。
2025-12-11 16:43:19 +08:00
fb94c2069d refactor(service): 移除 themeStatus 字段并新增 themeDownloadUrl
- 删除 VO 中的 themeStatus 属性
- 新增 themeDownloadUrl 用于前端下载
- 简化购买记录字段赋值顺序
2025-12-11 16:19:12 +08:00
e8ef359fcf feat(themes): 新增推荐主题与用户主题批量删除功能
- 新增 getRecommendedThemes:按真实下载量降序返回8个未购买主题
- 新增 batchDeleteUserThemes:支持用户批量逻辑删除已购主题
- 补充接口注释与 Swagger 文档,开放 /themes/recommended 免鉴权路径
2025-12-11 15:08:02 +08:00
567a8bf165 feat(wallet): 新增余额格式化显示字段
在响应对象中添加 balanceDisplay 字段,用于返回“K”缩写格式的大额余额,
2025-12-11 14:39:05 +08:00
f937b03940 feat(theme): 购买主题后自动写入用户主题表 2025-12-11 14:23:52 +08:00
262c822585 feat(theme): 新增主题详情查询接口
支持根据主题ID和用户ID查询主题详情,包含购买状态
2025-12-11 13:32:05 +08:00
77e8e9a2a7 feat(themes): 为主题列表接口增加用户购买状态标记
在 KeyboardThemesRespVO 中新增 isPurchased 字段;
selectThemesByStyle 方法增加 userId 参数并查询用户已购主题 ID,
返回结果标记当前用户是否已购买。
2025-12-10 20:36:01 +08:00
03dc005b38 feat(themes): 新增查询用户已购主题接口
在 KeyboardThemePurchaseService 及其实现中增加 getUserPurchasedThemes 方法,
通过用户ID获取已支付主题列表;同步新增 /themes/purchased 接口并放行鉴权。
补充 KeyboardThemesRespVO 缺失的 themePreviewImageUrl 字段。
2025-12-10 20:19:47 +08:00
1a6fb944b2 feat(theme): 支持购买记录查询并调整积分类型为BigDecimal
- 新增 /themes/purchase/list 接口,支持用户查询主题购买记录
- 将 KeyboardThemePurchase 中的积分字段由 Integer 改为 BigDecimal,确保金额精度
- 对应 Mapper XML 中 jdbcType 由 INTEGER 调整为 NUMERIC
- 补充 getUserPurchaseList 服务及返回 VO ThemePurchaseListRespVO
- 开放接口权限并完善跨域配置
2025-12-10 19:58:48 +08:00
22b97b99aa feat(purchase): 新增主题购买全流程接口
新增主题购买功能,包括余额校验、订单生成、交易记录等完整流程。同时扩展错误码支持余额不足、主题不存在等场景。
2025-12-10 19:40:27 +08:00
4f56541913 feat(wallet): 新增主题购买与钱包交易模块 2025-12-10 19:17:37 +08:00
0d1545f568 feat(wallet): 新增用户钱包余额查询功能 2025-12-10 18:52:38 +08:00
82 changed files with 3631 additions and 222 deletions

2
.gitignore vendored
View File

@@ -33,3 +33,5 @@ build/
### VS Code ###
.vscode/
/CLAUDE.md
/AGENTS.md
/src/test/

View File

@@ -1,33 +0,0 @@
# Repository Guidelines
## Project Structure & Module Organization
- Entrypoint `src/main/java/com/yolo/keyborad/MyApplication.java`; feature code organized by layer: `controller` (REST), `service` (business), `mapper` (MyBatis mappers), `model`/`common`/`constant` for DTOs, responses, and constants, plus `config`, `aop`, `annotation`, `Interceptor`, and `utils` for cross-cutting concerns.
- Resource configs live in `src/main/resources`: `application.yml` with `application-dev.yml`/`application-prod.yml` profiles, mapper XML files under `mapper/`, and platform keys/certs (Apple, mail, storage). Keep secrets out of commits.
- Tests belong in `src/test/java/com/yolo/keyborad/...` mirroring package names; add fixtures alongside tests when needed.
## Build, Test, and Development Commands
- `./mvnw clean install` — full build with tests; requires JDK 17.
- `./mvnw test` — run test suite only.
- `./mvnw spring-boot:run -Dspring-boot.run.profiles=dev` — start the API with the dev profile (loads `application-dev.yml`).
- `./mvnw clean package -DskipTests` — create an artifact when tests are already covered elsewhere.
## Coding Style & Naming Conventions
- Java 17, Spring Boot 3.5, MyBatis/MyBatis-Plus; prefer Lombok for boilerplate (`@Data`, `@Builder`) and constructor injection for services.
- Use 4-space indentation, lowercase package names, `UpperCamelCase` for classes, `lowerCamelCase` for fields/params.
- Controllers end with `*Controller`, services with `*Service`, mapper interfaces with `*Mapper`, and request/response DTOs under `model` or `common` with clear suffixes like `Request`/`Response`.
- Keep configuration isolated in `config`; shared constants in `constant`; AOP/logging in `aop`; custom annotations in `annotation`.
## Testing Guidelines
- Use Spring Boot Test + JUnit (via `spring-boot-starter-test`, JUnit 4/5 support) and MockMvc/WebTestClient for HTTP layers when practical.
- Name classes `*Test` and align packages with the code under test. Cover service logic, mappers, and controller contracts (status + payload shape).
- For data-access tests, use in-memory setups or dedicated test containers and clean up test data.
## Commit & Pull Request Guidelines
- Follow the existing conventional style seen in history (e.g., `feat(user): add email registration`); keep scope lowercase and concise.
- PRs should describe the change, list validation steps/commands run, call out config/profile impacts, and link issues/tasks. Add screenshots or sample requests/responses for API-facing changes when helpful.
- Ensure secrets (p8 certificates, Mailgun keys, AWS creds) are never committed; rely on environment variables or local config overrides.
## Security & Configuration Tips
- Activate the intended profile via `SPRING_PROFILES_ACTIVE` or `-Dspring-boot.run.profiles`. Keep `application-dev.yml` local-only; never hardcode production endpoints or credentials.
- Validate signing/encryption helpers (`SignInterceptor`, JWT, Apple receipt validation) with representative non-production keys before merging.
- Log only necessary context; avoid logging tokens, receipts, or PII.

0
mvnw vendored Normal file → Executable file
View File

View File

@@ -101,7 +101,7 @@
<dependency>
<groupId>com.apple.itunes.storekit</groupId>
<artifactId>app-store-server-library</artifactId>
<version>3.6.0</version>
<version>4.0.0</version>
</dependency>
<!-- x-file-storage -->

View File

@@ -46,7 +46,18 @@ public enum ErrorCode {
REPEATEDLY_ADDING_CHARACTER(50009, "重复添加键盘人设"),
MAIL_SEND_BUSY(50010,"邮件发送频繁1分钟后再试" ),
PASSWORD_CAN_NOT_NULL(50011, "密码不能为空" ),
USER_HAS_EXISTED(50012, "用户已存在" );
USER_HAS_EXISTED(50012, "用户已存在" ),
INSUFFICIENT_BALANCE(50013, "余额不足"),
THEME_NOT_FOUND(40410, "主题不存在"),
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
THEME_NOT_AVAILABLE(50015, "主题不可购买"),
RECEIPT_INVALID(50016, "收据无效"),
UPDATE_USER_VIP_STATUS_ERROR(50017, "更新用户VIP状态失败"),
PRODUCT_QUOTA_NOT_SET(50018, "商品额度未配置"),
LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"),
UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"),
PRODUCT_NOT_FOUND(50021, "商品不存在");
/**
* 状态码
*/

View File

@@ -81,12 +81,27 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/character/listByTagWithNotLogin",
"/character/detailWithNotLogin",
"/character/addUserCharacter",
"/api/apple/validate-receipt",
"/character/list",
"/user/resetPassWord",
"/chat/talk",
"/chat/save_embed",
"/themes/listByStyle"
"/themes/listByStyle",
"/wallet/balance",
"/themes/purchase",
"/themes/purchased",
"/themes/purchase/list",
"/themes/detail",
"/themes/recommended",
"/user-themes/batch-delete",
"/products/listByType",
"/products/detail",
"/products/inApp/list",
"/products/subscription/list",
"/purchase/handle",
"/apple/notification",
"/apple/receipt",
"/apple/validate-receipt"
};
}
@Bean

View File

@@ -1,27 +1,230 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
import com.apple.itunes.storekit.verification.SignedDataVerifier;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.model.dto.AppleServerNotification;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.AppleReceiptService;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j;
import java.time.Instant;
import java.util.Base64;
import java.util.Locale;
import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@RestController
@RequestMapping("/api/apple")
@RequestMapping("/apple")
@Slf4j
public class AppleReceiptController {
private final AppleReceiptService appleReceiptService;
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
public AppleReceiptController(AppleReceiptService appleReceiptService) {
private final AppleReceiptService appleReceiptService;
private final ApplePurchaseService applePurchaseService;
private final SignedDataVerifier signedDataVerifier;
public AppleReceiptController(AppleReceiptService appleReceiptService,
ApplePurchaseService applePurchaseService,
SignedDataVerifier signedDataVerifier) {
this.appleReceiptService = appleReceiptService;
this.applePurchaseService = applePurchaseService;
this.signedDataVerifier = signedDataVerifier;
}
@PostMapping("/receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String receipt = body.get("receipt");
if (receipt == null || receipt.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空");
}
return appleReceiptService.validateReceipt(receipt);
}
@PostMapping("/validate-receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
String receipt = body.get("receipt");
return appleReceiptService.validateReceipt(receipt);
public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String receipt = body.get("receipt");
if (receipt == null || receipt.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空");
}
Long userId = StpUtil.getLoginIdAsLong();
AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(receipt);
applePurchaseService.processPurchase(userId, validationResult);
return ResultUtils.success(Boolean.TRUE);
}
/**
* 接收 Apple 服务器通知
* 处理来自 Apple 的服务器到服务器通知,主要用于订阅续订等事件
*
* @param body 请求体,包含 signedPayload 字段
* @param request HTTP 请求对象
* @return 处理结果
* @throws BusinessException 当 signedPayload 为空或解析失败时抛出
*/
@PostMapping("/notification")
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
// 从请求体中获取 Apple 签名的载荷
String signedPayload = body.get("signedPayload");
// 校验 signedPayload 是否为空
if (signedPayload == null || signedPayload.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
}
// 解码签名载荷,获取通知详情
AppleServerNotification notification = decodeSignedPayload(signedPayload);
log.info("Apple server notification decoded, type={}, env={}, query={}",
notification.getNotificationType(),
notification.getEnvironment(),
request != null ? request.getQueryString() : null);
// 判断是否为续订相关通知,如果是则进行处理
String type = notification.getNotificationType();
if (type != null
&& type.toUpperCase(Locale.ROOT).contains("RENEW")
&& notification.getSignedTransactionInfo() != null
&& !notification.getSignedTransactionInfo().isBlank()
&& notification.getOriginalTransactionId() != null
&& !notification.getOriginalTransactionId().isBlank()) {
applePurchaseService.processRenewNotification(notification);
}
return ResultUtils.success(Boolean.TRUE);
}
/**
* 解码 Apple 签名的 JWT 载荷
* 从 signedPayload 中解析出服务器通知的详细信息包括通知类型、环境、产品ID、交易信息等
*
* @param signedPayload Apple 服务器发送的签名载荷JWT 格式)
* @return AppleServerNotification 对象,包含解析后的通知详情
* @throws BusinessException 当参数无效或解析失败时抛出
*/
private AppleServerNotification decodeSignedPayload(String signedPayload) {
try {
// 外层 notification 的 signedPayload 仅用于取出 signedTransactionInfo
// 实际交易相关字段以 SignedDataVerifier 验签后的 transaction payload 为准,避免信任未验签的数据。
JsonNode root = parseJwtPayloadWithoutVerification(signedPayload);
AppleServerNotification notification = new AppleServerNotification();
// 从 JWT payload 根节点获取通知类型(支持驼峰和下划线两种命名格式)
notification.setNotificationType(text(root, "notificationType", "notification_type"));
// 解析 data 节点中的基本信息(环境和签名的交易信息)
JsonNode data = root.get("data");
if (data != null && !data.isNull()) {
// 提取运行环境Sandbox 或 Production
notification.setEnvironment(text(data, "environment"));
// 提取签名的交易信息 JWT 字符串
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
}
// 如果存在签名的交易信息,使用官方 SignedDataVerifier 进行验签并解码
String signedTransactionInfo = notification.getSignedTransactionInfo();
if (signedTransactionInfo != null && !signedTransactionInfo.isBlank()) {
// 调用 Apple 官方 SDK 验证签名并解码交易载荷
JWSTransactionDecodedPayload txPayload =
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
// 设置交易 ID当前交易的唯一标识
notification.setTransactionId(txPayload.getTransactionId());
// 设置产品 ID购买的商品标识符
notification.setProductId(txPayload.getProductId());
// 设置原始交易 ID用于标识订阅组或首次购买
notification.setOriginalTransactionId(txPayload.getOriginalTransactionId());
// 如果存在购买日期,将时间戳转换为 ISO 8601 格式字符串
if (txPayload.getPurchaseDate() != null) {
notification.setPurchaseDate(Instant.ofEpochMilli(txPayload.getPurchaseDate()).toString());
}
// 如果存在过期日期(订阅类商品),将时间戳转换为 ISO 8601 格式字符串
if (txPayload.getExpiresDate() != null) {
notification.setExpiresDate(Instant.ofEpochMilli(txPayload.getExpiresDate()).toString());
}
// 如果外层未提供环境信息,则从交易载荷中获取并设置
if ((notification.getEnvironment() == null || notification.getEnvironment().isBlank())
&& txPayload.getEnvironment() != null) {
notification.setEnvironment(txPayload.getEnvironment().name());
}
}
return notification;
} catch (IllegalArgumentException e) {
// 捕获参数校验异常,包装后抛出业务异常
throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage());
} catch (Exception e) {
// 捕获其他异常(如验签失败、解析失败等),记录日志并抛出操作异常
log.error("Failed to decode signedPayload", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "signedPayload 解析失败");
}
}
/**
* 解析 JWT 载荷内容(无验签)
* 仅用于从 JWT 中提取 payload 部分的 JSON 数据,不进行签名验证
* 注意:此方法不验证 JWT 签名,仅用于快速解析结构,实际交易数据需通过 SignedDataVerifier 验签
*
* @param jwt JWT 字符串,格式为 header.payload.signature
* @return 解析后的 JSON 节点,包含 payload 中的数据
* @throws IllegalArgumentException 当 JWT 格式无效时抛出
* @throws Exception 当 Base64 解码或 JSON 解析失败时抛出
*/
private JsonNode parseJwtPayloadWithoutVerification(String jwt) throws Exception {
// 按点号分割 JWT标准 JWT 包含三部分header.payload.signature
String[] parts = jwt.split("\\.");
if (parts.length < 2) {
throw new IllegalArgumentException("Invalid JWT format");
}
// 对 payload 部分进行 Base64 URL 解码
byte[] payloadBytes = Base64.getUrlDecoder().decode(parts[1]);
// 将解码后的字节数组解析为 JSON 树结构
return OBJECT_MAPPER.readTree(payloadBytes);
}
/**
* 从 JSON 节点中提取文本值
* 支持多个候选键名,按顺序尝试获取第一个非空值
*
* @param node JSON 节点
* @param keys 候选的键名列表(支持驼峰和下划线命名)
* @return 找到的第一个非空文本值,如果都不存在则返回 null
*/
private String text(JsonNode node, String... keys) {
for (String k : keys) {
if (node != null && node.has(k) && !node.get(k).isNull()) {
return node.get(k).asText();
}
}
return null;
}
}

View File

@@ -0,0 +1,71 @@
package com.yolo.keyborad.controller;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.products.KeyboardProductItemRespVO;
import com.yolo.keyborad.service.KeyboardProductItemsService;
import io.swagger.v3.oas.annotations.Operation;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/12
*/
@RestController
@Slf4j
@RequestMapping("/products")
@Tag(name = "商品", description = "商品相关接口")
public class ProductsController {
@Resource
private KeyboardProductItemsService productItemsService;
@GetMapping("/detail")
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
@RequestParam(value = "id", required = false) Long id,
@RequestParam(value = "productId", required = false) String productId
) {
if (id == null && (productId == null || productId.isBlank())) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
}
KeyboardProductItemRespVO result = (id != null)
? productItemsService.getProductDetailById(id)
: productItemsService.getProductDetailByProductId(productId);
return ResultUtils.success(result);
}
@GetMapping("/listByType")
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表type=all 返回全部")
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(@RequestParam("type") String type) {
if (type == null || type.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
}
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
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");
return ResultUtils.success(result);
}
@GetMapping("/subscription/list")
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
return ResultUtils.success(result);
}
}

View File

@@ -1,19 +1,21 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.purchase.ThemePurchaseReq;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseListRespVO;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseRespVO;
import com.yolo.keyborad.model.vo.themes.KeyboardThemeStylesRespVO;
import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
import com.yolo.keyborad.service.KeyboardThemeStylesService;
import com.yolo.keyborad.service.KeyboardThemesService;
import io.swagger.v3.oas.annotations.Operation;
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.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@@ -33,12 +35,15 @@ public class ThemesController {
@Resource
private KeyboardThemeStylesService keyboardThemeStylesService;
@Resource
private KeyboardThemePurchaseService themePurchaseService;
@GetMapping("/listByStyle")
@Operation(summary = "按风格查询主题", description = "按主题风格查询主题列表接口")
public BaseResponse<List<KeyboardThemesRespVO>> listByStyle(@RequestParam("themeStyle") Long themeStyleId) {
return ResultUtils.success(themesService.selectThemesByStyle(themeStyleId));
}
Long userId = StpUtil.getLoginIdAsLong();
return ResultUtils.success(themesService.selectThemesByStyle(themeStyleId,userId));}
@GetMapping("/listAllStyles")
@Operation(summary = "查询所有主题风格", description = "查询所有主题风格列表接口")
@@ -46,5 +51,44 @@ public class ThemesController {
return ResultUtils.success(keyboardThemeStylesService.selectAllThemeStyles());
}
@PostMapping("/purchase")
@Operation(summary = "购买主题", description = "购买主题接口,扣减用户余额")
public BaseResponse<ThemePurchaseRespVO> purchaseTheme(@RequestBody ThemePurchaseReq req) {
Long userId = StpUtil.getLoginIdAsLong();
ThemePurchaseRespVO result = themePurchaseService.purchaseTheme(userId, req.getThemeId());
return ResultUtils.success(result);
}
@GetMapping("/purchase/list")
@Operation(summary = "查询购买记录", description = "查询当前用户的主题购买记录")
public BaseResponse<List<ThemePurchaseListRespVO>> getPurchaseList() {
Long userId = StpUtil.getLoginIdAsLong();
List<ThemePurchaseListRespVO> result = themePurchaseService.getUserPurchaseList(userId);
return ResultUtils.success(result);
}
@GetMapping("/purchased")
@Operation(summary = "查询已购买的主题", description = "查询当前用户已购买的主题列表")
public BaseResponse<List<KeyboardThemesRespVO>> getPurchasedThemes() {
Long userId = StpUtil.getLoginIdAsLong();
List<KeyboardThemesRespVO> result = themePurchaseService.getUserPurchasedThemes(userId);
return ResultUtils.success(result);
}
@GetMapping("/detail")
@Operation(summary = "查询主题详情", description = "根据主题ID查询主题详情")
public BaseResponse<KeyboardThemesRespVO> getThemeDetail(@RequestParam Long themeId) {
Long userId = StpUtil.getLoginIdAsLong();
KeyboardThemesRespVO result = themesService.getThemeDetail(themeId, userId);
return ResultUtils.success(result);
}
@GetMapping("/recommended")
@Operation(summary = "推荐主题列表", description = "按真实下载数量降序返回推荐主题")
public BaseResponse<List<KeyboardThemesRespVO>> getRecommendedThemes() {
Long userId = StpUtil.getLoginIdAsLong();
List<KeyboardThemesRespVO> result = themesService.getRecommendedThemes(userId);
return ResultUtils.success(result);
}
}

View File

@@ -15,12 +15,15 @@ import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
import java.text.SimpleDateFormat;
/**
* 用户前端控制器
*
@@ -47,8 +50,8 @@ public class UserController {
@PostMapping("/appleLogin")
@Operation(summary = "苹果登录", description = "苹果登录接口")
@Parameter(name = "code", required = true, description = "苹果登录凭证", example = "123456")
public BaseResponse<KeyboardUserRespVO> appleLogin(@RequestBody AppleLoginReq appleLoginReq) throws Exception {
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken()));
public BaseResponse<KeyboardUserRespVO> appleLogin(@RequestBody AppleLoginReq appleLoginReq, HttpServletRequest request) throws Exception {
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken(), request));
}
@GetMapping("/logout")
@@ -60,8 +63,8 @@ public class UserController {
@PostMapping("/login")
@Operation(summary = "登录", description = "登录接口")
public BaseResponse<KeyboardUserRespVO> login(@RequestBody UserLoginDTO userLoginDTO) {
return ResultUtils.success(userService.login(userLoginDTO));
public BaseResponse<KeyboardUserRespVO> login(@RequestBody UserLoginDTO userLoginDTO, HttpServletRequest request) {
return ResultUtils.success(userService.login(userLoginDTO, request));
}
@PostMapping("/updateInfo")
@@ -75,7 +78,17 @@ public class UserController {
public BaseResponse<KeyboardUserInfoRespVO> detail() {
long loginId = StpUtil.getLoginIdAsLong();
KeyboardUser keyboardUser = userService.getById(loginId);
return ResultUtils.success(BeanUtil.copyProperties(keyboardUser, KeyboardUserInfoRespVO.class));
KeyboardUserInfoRespVO respVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserInfoRespVO.class);
// 格式化VIP到期时间
if (keyboardUser.getVipExpiry() != null) {
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
respVO.setVipExpiry(sdf.format(keyboardUser.getVipExpiry()));
} else {
respVO.setVipExpiry(null);
}
return ResultUtils.success(respVO);
}
@PostMapping("/register")

View File

@@ -0,0 +1,34 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.dto.usertheme.BatchDeleteUserThemesReq;
import com.yolo.keyborad.service.KeyboardUserThemesService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
/*
* @author: ziin
* @date: 2025/12/11
*/
@RestController
@Slf4j
@RequestMapping("/user-themes")
@Tag(name = "用户主题")
public class UserThemesController {
@Resource
private KeyboardUserThemesService userThemesService;
@PostMapping("/batch-delete")
@Operation(summary = "批量删除用户主题", description = "批量逻辑删除用户主题")
public BaseResponse<Boolean> batchDeleteUserThemes(@RequestBody BatchDeleteUserThemesReq req) {
Long userId = StpUtil.getLoginIdAsLong();
userThemesService.batchDeleteUserThemes(userId, req.getThemeIds());
return ResultUtils.success(true);
}
}

View File

@@ -0,0 +1,36 @@
package com.yolo.keyborad.controller;
import cn.dev33.satoken.stp.StpUtil;
import com.yolo.keyborad.common.BaseResponse;
import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.model.vo.wallet.KeyboardUserWalletRespVO;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import io.swagger.v3.oas.annotations.Operation;
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.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/*
* @author: ziin
* @date: 2025/12/10
*/
@RestController
@Slf4j
@RequestMapping("/wallet")
@Tag(name = "钱包", description = "用户钱包接口")
public class WalletController {
@Resource
private KeyboardUserWalletService walletService;
@GetMapping("/balance")
@Operation(summary = "查询钱包余额", description = "查询当前登录用户的钱包余额")
public BaseResponse<KeyboardUserWalletRespVO> getBalance() {
Long userId = StpUtil.getLoginIdAsLong();
KeyboardUserWalletRespVO balance = walletService.getWalletBalance(userId);
return ResultUtils.success(balance);
}
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
public interface KeyboardProductItemsMapper extends BaseMapper<KeyboardProductItems> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
/*
* @author: ziin
* @date: 2025/12/10 19:17
*/
public interface KeyboardThemePurchaseMapper extends BaseMapper<KeyboardThemePurchase> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserLoginLog;
/*
* @author: ziin
* @date: 2025/12/11 20:09
*/
public interface KeyboardUserLoginLogMapper extends BaseMapper<KeyboardUserLoginLog> {
}

View File

@@ -1,13 +1,13 @@
package com.yolo.keyborad.mapper;
/*
* @author: ziin
* @date: 2025/12/2 18:10
*/
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUser;
/*
* @author: ziin
* @date: 2025/12/11 20:35
*/
public interface KeyboardUserMapper extends BaseMapper<KeyboardUser> {
Integer updateByuid(KeyboardUser keyboardUser);
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
public interface KeyboardUserPurchaseRecordsMapper extends BaseMapper<KeyboardUserPurchaseRecords> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserThemes;
/*
* @author: ziin
* @date: 2025/12/11 13:31
*/
public interface KeyboardUserThemesMapper extends BaseMapper<KeyboardUserThemes> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
/*
* @author: ziin
* @date: 2025/12/10 18:18
*/
public interface KeyboardUserWalletMapper extends BaseMapper<KeyboardUserWallet> {
}

View File

@@ -0,0 +1,12 @@
package com.yolo.keyborad.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
/*
* @author: ziin
* @date: 2025/12/10 18:54
*/
public interface KeyboardWalletTransactionMapper extends BaseMapper<KeyboardWalletTransaction> {
}

View File

@@ -0,0 +1,41 @@
package com.yolo.keyborad.model.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Data;
/**
* Apple 服务器通知(精简字段)
*/
@Data
public class AppleServerNotification {
@JsonProperty("notification_type")
private String notificationType;
@JsonProperty("auto_renew_status")
private String autoRenewStatus;
@JsonProperty("app_account_token")
private String appAccountToken;
@JsonProperty("original_transaction_id")
private String originalTransactionId;
@JsonProperty("product_id")
private String productId;
@JsonProperty("purchase_date")
private String purchaseDate;
@JsonProperty("expires_date")
private String expiresDate;
@JsonProperty("environment")
private String environment;
@JsonProperty("transaction_id")
private String transactionId;
@JsonProperty("signed_transaction_info")
private String signedTransactionInfo;
}

View File

@@ -0,0 +1,14 @@
package com.yolo.keyborad.model.dto.purchase;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
/**
* 主题购买请求
*/
@Data
public class ThemePurchaseReq {
@Schema(description = "主题ID")
private Long themeId;
}

View File

@@ -0,0 +1,20 @@
package com.yolo.keyborad.model.dto.usertheme;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/11
*/
@Schema(description = "批量删除用户主题请求")
@Data
public class BatchDeleteUserThemesReq {
/**
* 主题ID列表
*/
@Schema(description = "主题ID列表")
private List<Long> themeIds;
}

View File

@@ -13,7 +13,7 @@ import lombok.Data;
@Schema(description="多语言消息表")
@Data
@TableName("i18n_message")
@TableName("keyboard_i18n_message")
public class I18nMessage {
@TableId("id")
@Schema(description="主键")

View File

@@ -0,0 +1,111 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
@Schema
@Data
@TableName(value = "keyboard_product_items")
public class KeyboardProductItems {
/**
* 主键,自增,唯一标识每个产品项
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键,自增,唯一标识每个产品项")
private Long id;
/**
* 产品标识符,唯一标识每个产品(如 com.loveKey.nyx.2month
*/
@TableField(value = "product_id")
@Schema(description="产品标识符,唯一标识每个产品(如 com.loveKey.nyx.2month")
private String productId;
/**
* 产品类型区分订阅subscription和内购in-app-purchase
*/
@TableField(value = "\"type\"")
@Schema(description="产品类型区分订阅subscription和内购in-app-purchase")
private String type;
/**
* 产品名称(如 100, 2
*/
@TableField(value = "\"name\"")
@Schema(description="产品名称(如 100, 2")
private String name;
/**
* 产品单位(如 金币,个月)
*/
@TableField(value = "unit")
@Schema(description="产品单位(如 金币,个月)")
private String unit;
/**
* 订阅时长的数值部分(如 2
*/
@TableField(value = "duration_value")
@Schema(description="订阅时长的数值部分(如 2")
private Integer durationValue;
/**
* 订阅时长的单位部分(如 月,天)
*/
@TableField(value = "duration_unit")
@Schema(description="订阅时长的单位部分(如 月,天)")
private String durationUnit;
/**
* 产品价格
*/
@TableField(value = "price")
@Schema(description="产品价格")
private BigDecimal price;
/**
* 产品的货币单位,如美元($
*/
@TableField(value = "currency")
@Schema(description="产品的货币单位,如美元($")
private String currency;
/**
* 产品的描述,提供更多细节信息
*/
@TableField(value = "description")
@Schema(description="产品的描述,提供更多细节信息")
private String description;
/**
* 产品项的创建时间,默认当前时间
*/
@TableField(value = "created_at")
@Schema(description="产品项的创建时间,默认当前时间")
private Date createdAt;
/**
* 产品项的最后更新时间,更新时自动设置为当前时间
*/
@TableField(value = "updated_at")
@Schema(description="产品项的最后更新时间,更新时自动设置为当前时间")
private Date updatedAt;
/**
* 订阅时长的具体天数
*/
@TableField(value = "duration_days")
@Schema(description="订阅时长的具体天数")
private Integer durationDays;
}

View File

@@ -0,0 +1,119 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/10 19:17
*/
/**
* 皮肤购买记录表(积分支付)
*/
@Schema(description="皮肤购买记录表(积分支付)")
@Data
@TableName(value = "keyboard_theme_purchase")
public class KeyboardThemePurchase {
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="")
private Long id;
/**
* 业务订单号
*/
@TableField(value = "order_no")
@Schema(description="业务订单号")
private String orderNo;
/**
* 购买用户ID
*/
@TableField(value = "user_id")
@Schema(description="购买用户ID")
private Long userId;
/**
* 主题皮肤ID
*/
@TableField(value = "theme_id")
@Schema(description="主题皮肤ID")
private Long themeId;
/**
* 皮肤原始所需积分
*/
@TableField(value = "cost_points")
@Schema(description="皮肤原始所需积分")
private BigDecimal costPoints;
/**
* 实际扣除积分
*/
@TableField(value = "paid_points")
@Schema(description="实际扣除积分")
private BigDecimal paidPoints;
/**
* 支付状态0待支付 1已支付 2已关闭 3已退款
*/
@TableField(value = "pay_status")
@Schema(description="支付状态0待支付 1已支付 2已关闭 3已退款")
private Short payStatus;
/**
* 关联的积分扣费流水ID
*/
@TableField(value = "wallet_tx_id")
@Schema(description="关联的积分扣费流水ID")
private Long walletTxId;
/**
* 已退款的积分数量
*/
@TableField(value = "refund_points")
@Schema(description="已退款的积分数量")
private Integer refundPoints;
/**
* 积分退款完成时间
*/
@TableField(value = "refunded_at")
@Schema(description="积分退款完成时间")
private Date refundedAt;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description="创建时间")
private Date createdAt;
/**
* 支付完成时间
*/
@TableField(value = "paid_at")
@Schema(description="支付完成时间")
private Date paidAt;
/**
* 更新时间
*/
@TableField(value = "updated_at")
@Schema(description="更新时间")
private Date updatedAt;
/**
* 备注或扩展信息
*/
@TableField(value = "remark")
@Schema(description="备注或扩展信息")
private String remark;
}

View File

@@ -8,6 +8,8 @@ import com.yolo.keyborad.typehandler.StringArrayTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import lombok.Data;
/*
@@ -31,9 +33,9 @@ public class KeyboardThemes {
@Schema(description="键盘价格")
private BigDecimal themePrice;
@TableField(value = "theme_tag", typeHandler = StringArrayTypeHandler.class)
@TableField(value = "theme_tag")
@Schema(description="主题标签")
private String[] themeTag;
private List<ThemeTagItem> themeTag;
@TableField(value = "theme_download")
@Schema(description="主题下载次数")

View File

@@ -1,83 +1,124 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/2 18:08
* @date: 2025/12/11 20:35
*/
@Schema
@Data
@Schema(description="用户信息")
@TableName(value = "keyboard_user")
public class KeyboardUser {
@Schema(description="主键ID")
@TableId(type = IdType.AUTO)
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键")
private Long id;
@Schema(description="用户ID")
/**
* 用户 Id
*/
@TableField(value = "\"uid\"")
@Schema(description = "用户 Id")
private Long uid;
@Schema(description="用户昵称")
/**
* 用户昵称
*/
@TableField(value = "nick_name")
@Schema(description = "用户昵称")
private String nickName;
@Schema(description="性别")
/**
* 性别
*/
@TableField(value = "gender")
@Schema(description = "性别")
private Integer gender;
@Schema(description="头像URL")
/**
* 头像地址
*/
@TableField(value = "avatar_url")
@Schema(description = "头像地址")
private String avatarUrl;
/**
* 创建时间
*/
@Schema(description="创建时间")
@TableField(value = "created_at")
@Schema(description = "创建时间")
private Date createdAt;
/**
* 更新时间
*/
@Schema(description="更新时间")
@TableField(value = "updated_at")
@Schema(description = "更新时间")
private Date updatedAt;
/**
* 是否删除(默认否)
*/
@Schema(description="是否删除(默认否)")
@TableField(value = "deleted")
@Schema(description = "是否删除(默认否)")
private Boolean deleted;
/**
* 邮箱地址
*/
@Schema(description="邮箱地址")
@TableField(value = "email")
@Schema(description = "邮箱地址")
private String email;
/**
* 是否禁用
*/
@Schema(description="是否禁用")
@TableField(value = "\"status\"")
@Schema(description = "是否禁用")
private Boolean status;
/**
* 密码
*/
@Schema(description="密码")
@TableField(value = "\"password\"")
@Schema(description = "密码")
private String password;
/**
* 苹果登录subjectId
*/
@Schema(description="苹果登录subjectId")
@TableField(value = "subject_id")
@Schema(description = "苹果登录subjectId")
private String subjectId;
/**
* 邮箱是否验证
*/
@Schema(description="邮箱是否验证")
@TableField(value = "email_verified")
@Schema(description = "邮箱是否验证")
private Boolean emailVerified;
/**
* 是否是 VIP
*/
@TableField(value = "is_vip")
@Schema(description = "是否是 VIP")
private Boolean isVip;
/**
* VIP 过期时间
*/
@TableField(value = "vip_expiry")
@Schema(description = "VIP 过期时间")
private Date vipExpiry;
}

View File

@@ -0,0 +1,89 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/11 20:09
*/
@Schema
@Data
@TableName(value = "keyboard_user_login_log")
public class KeyboardUserLoginLog {
/**
* 主键
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键")
private Long id;
/**
* 用户 ID关联到 keyboard_user 表
*/
@TableField(value = "user_id")
@Schema(description="用户 ID关联到 keyboard_user 表")
private Long userId;
/**
* 登录时间
*/
@TableField(value = "login_time")
@Schema(description="登录时间")
private Date loginTime;
/**
* 登录的 IP 地址
*/
@TableField(value = "ip_address")
@Schema(description="登录的 IP 地址")
private String ipAddress;
/**
* 用户设备信息
*/
@TableField(value = "device_info")
@Schema(description="用户设备信息")
private String deviceInfo;
/**
* 操作系统
*/
@TableField(value = "os")
@Schema(description="操作系统")
private String os;
/**
* 设备平台iOS 或 Android
*/
@TableField(value = "platform")
@Schema(description="设备平台iOS 或 Android")
private String platform;
/**
* 登录状态,成功或失败
*/
@TableField(value = "\"status\"")
@Schema(description="登录状态,成功或失败")
private String status;
/**
* 记录创建时间
*/
@TableField(value = "created_at")
@Schema(description="记录创建时间")
private Date createdAt;
/**
* 记录更新时间
*/
@TableField(value = "updated_at")
@Schema(description="记录更新时间")
private Date updatedAt;
}

View File

@@ -0,0 +1,134 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.yolo.keyborad.typehandler.StringArrayTypeHandler;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
import org.apache.ibatis.type.JdbcType;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Schema
@Data
@TableName(value = "keyboard_user_purchase_records")
public class KeyboardUserPurchaseRecords {
/**
* 主键,自增,唯一标识每条购买记录
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键,自增,唯一标识每条购买记录")
private Integer id;
/**
* 用户 ID关联到用户表表示是哪位用户购买了产品
*/
@TableField(value = "user_id")
@Schema(description="用户 ID关联到用户表表示是哪位用户购买了产品")
private Integer userId;
/**
* 购买的产品 ID关联到产品表
*/
@TableField(value = "product_id")
@Schema(description="购买的产品 ID关联到产品表")
private String productId;
/**
* 购买数量(如内购的金币数量,订阅的时长)
*/
@TableField(value = "purchase_quantity")
@Schema(description="购买数量(如内购的金币数量,订阅的时长)")
private Integer purchaseQuantity;
/**
* 实际支付价格
*/
@TableField(value = "price")
@Schema(description="实际支付价格")
private BigDecimal price;
/**
* 货币类型(如美元 $
*/
@TableField(value = "currency")
@Schema(description="货币类型(如美元 $")
private String currency;
/**
* 购买时间
*/
@TableField(value = "purchase_time")
@Schema(description="购买时间")
private Date purchaseTime;
/**
* 购买类型(如内购,订阅)
*/
@TableField(value = "purchase_type")
@Schema(description="购买类型(如内购,订阅)")
private String purchaseType;
/**
* 购买状态(如已支付,待支付,退款)
*/
@TableField(value = "\"status\"")
@Schema(description="购买状态(如已支付,待支付,退款)")
private String status;
/**
* 支付方式(如信用卡,支付宝等)
*/
@TableField(value = "payment_method")
@Schema(description="支付方式(如信用卡,支付宝等)")
private String paymentMethod;
/**
* 唯一的交易 ID用于标识该购买操作
*/
@TableField(value = "transaction_id")
@Schema(description="唯一的交易 ID用于标识该购买操作")
private String transactionId;
/**
* 苹果的原始交易 ID
*/
@TableField(value = "original_transaction_id")
@Schema(description="苹果的原始交易 ID")
private String originalTransactionId;
/**
* 购买的产品 ID 列表JSON 格式或数组)
*/
@TableField(value = "product_ids", typeHandler = StringArrayTypeHandler.class, jdbcType = JdbcType.ARRAY)
@Schema(description="购买的产品 ID 列表JSON 格式或数组)")
private String[] productIds;
/**
* 苹果返回的购买时间
*/
@TableField(value = "purchase_date")
@Schema(description="苹果返回的购买时间")
private Date purchaseDate;
/**
* 苹果返回的过期时间(如果有)
*/
@TableField(value = "expires_date")
@Schema(description="苹果返回的过期时间(如果有)")
private Date expiresDate;
/**
* 苹果的环境(如 Sandbox 或 Production
*/
@TableField(value = "environment")
@Schema(description="苹果的环境(如 Sandbox 或 Production")
private String environment;
}

View File

@@ -0,0 +1,61 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/11 13:31
*/
@Schema
@Data
@TableName(value = "keyboard_user_themes")
public class KeyboardUserThemes {
/**
* 主键 id
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键 id")
private Long id;
/**
* 主题主键
*/
@TableField(value = "theme_id")
@Schema(description="主题主键")
private Long themeId;
/**
* 用户 Id
*/
@TableField(value = "user_id")
@Schema(description="用户 Id")
private Long userId;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description="创建时间")
private Date createdAt;
/**
* 是否从显示移除
*/
@TableField(value = "view_deleted")
@Schema(description="是否从显示移除")
private Boolean viewDeleted;
/**
* 更新时间
*/
@TableField(value = "updated_at")
@Schema(description="更新时间")
private Boolean updatedAt;
}

View File

@@ -0,0 +1,69 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/10 18:18
*/
@Schema
@Data
@TableName(value = "keyboard_user_wallet")
public class KeyboardUserWallet {
/**
* 主键 Id
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description = "主键 Id")
private Long id;
/**
* 用户 id
*/
@TableField(value = "user_id")
@Schema(description = "用户 id")
private Long userId;
/**
* 余额
*/
@TableField(value = "balance")
@Schema(description = "余额")
private BigDecimal balance;
/**
* 乐观锁版本
*/
@TableField(value = "version")
@Schema(description = "乐观锁版本")
private Integer version;
/**
* 状态
*/
@TableField(value = "\"status\"")
@Schema(description = "状态")
private Short status;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description = "创建时间")
private Date createdAt;
/**
* 更新时间
*/
@TableField(value = "updated_at")
@Schema(description = "更新时间")
private Date updatedAt;
}

View File

@@ -0,0 +1,83 @@
package com.yolo.keyborad.model.entity;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import java.util.Date;
import lombok.Data;
/*
* @author: ziin
* @date: 2025/12/10 18:54
*/
@Schema
@Data
@TableName(value = "keyboard_wallet_transaction")
public class KeyboardWalletTransaction {
/**
* 主键 Id
*/
@TableId(value = "id", type = IdType.AUTO)
@Schema(description="主键 Id")
private Long id;
/**
* 用户 Id
*/
@TableField(value = "user_id")
@Schema(description="用户 Id")
private Long userId;
/**
* 订单 Id
*/
@TableField(value = "order_id")
@Schema(description="订单 Id")
private Long orderId;
/**
* 金额
*/
@TableField(value = "amount")
@Schema(description="金额")
private BigDecimal amount;
/**
* 变动类型
*/
@TableField(value = "\"type\"")
@Schema(description="变动类型")
private Short type;
/**
* 变动前余额
*/
@TableField(value = "before_balance")
@Schema(description="变动前余额")
private BigDecimal beforeBalance;
/**
* 变动后余额
*/
@TableField(value = "after_balance")
@Schema(description="变动后余额")
private BigDecimal afterBalance;
/**
* 描述
*/
@TableField(value = "description")
@Schema(description="描述")
private String description;
/**
* 创建时间
*/
@TableField(value = "created_at")
@Schema(description="创建时间")
private Date createdAt;
}

View File

@@ -0,0 +1,10 @@
package com.yolo.keyborad.model.entity;
import lombok.Data;
// ThemeTagItem.java
@Data
public class ThemeTagItem {
private String label;
private String color;
}

View File

@@ -0,0 +1,48 @@
package com.yolo.keyborad.model.vo.products;
import io.swagger.v3.oas.annotations.media.Schema;
import java.math.BigDecimal;
import lombok.Data;
/**
* 商品明细返回 VO
*/
@Data
@Schema(description = "商品明细返回对象")
public class KeyboardProductItemRespVO {
@Schema(description = "主键ID")
private Long id;
@Schema(description = "产品标识符,如 com.loveKey.nyx.2month")
private String productId;
@Schema(description = "产品类型subscription / in-app-purchase")
private String type;
@Schema(description = "产品名称")
private String name;
@Schema(description = "产品单位")
private String unit;
@Schema(description = "订阅时长数值")
private Integer durationValue;
@Schema(description = "订阅时长单位")
private String durationUnit;
@Schema(description = "订阅时长天数")
private Integer durationDays;
@Schema(description = "价格")
private BigDecimal price;
@Schema(description = "货币单位")
private String currency;
@Schema(description = "描述")
private String description;
}

View File

@@ -0,0 +1,36 @@
package com.yolo.keyborad.model.vo.purchase;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.Date;
/**
* 主题购买记录响应
*/
@Schema(description = "主题购买记录响应")
@Data
public class ThemePurchaseListRespVO {
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "主题ID")
private Long themeId;
@Schema(description = "主题名称")
private String themeName;
@Schema(description = "支付金额")
private BigDecimal paidAmount;
@Schema(description = "支付状态0待支付 1已支付 2已关闭 3已退款")
private Short payStatus;
@Schema(description = "购买时间")
private Date createdAt;
@Schema(description = "支付时间")
private Date paidAt;
}

View File

@@ -0,0 +1,26 @@
package com.yolo.keyborad.model.vo.purchase;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/**
* 主题购买响应
*/
@Schema(description = "主题购买响应")
@Data
public class ThemePurchaseRespVO {
@Schema(description = "订单号")
private String orderNo;
@Schema(description = "主题ID")
private Long themeId;
@Schema(description = "支付金额")
private BigDecimal paidAmount;
@Schema(description = "剩余余额")
private BigDecimal remainingBalance;
}

View File

@@ -1,9 +1,11 @@
package com.yolo.keyborad.model.vo.themes;
import com.yolo.keyborad.model.entity.ThemeTagItem;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
/*
* @author: ziin
@@ -34,7 +36,7 @@ public class KeyboardThemesRespVO {
* 主题标签
*/
@Schema(description = "主题标签")
private String[] themeTag;
private List<ThemeTagItem> themeTag;
/**
* 主题下载次数
@@ -48,11 +50,11 @@ public class KeyboardThemesRespVO {
@Schema(description = "主题风格")
private Long themeStyle;
/**
* 主题状态
*/
@Schema(description = "主题状态")
private Boolean themeStatus;
@Schema(description = "预览图")
private String themePreviewImageUrl;
@Schema(description = "下载地址")
private String themeDownloadUrl;
/**
* 主题购买次数
@@ -65,4 +67,7 @@ public class KeyboardThemesRespVO {
@Schema(description = "是否免费")
private Boolean isFree;
@Schema(description = "当前用户是否已购买")
private Boolean isPurchased;
}

View File

@@ -3,6 +3,8 @@ package com.yolo.keyborad.model.vo.user;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.Date;
/*
* @author: ziin
* @date: 2025/12/2 18:08
@@ -38,4 +40,16 @@ public class KeyboardUserInfoRespVO {
@Schema(description="邮箱是否验证")
private Boolean emailVerified;
/**
* 是否是 VIP
*/
@Schema(description = "是否是 VIP")
private Boolean isVip;
/**
* VIP 过期时间
*/
@Schema(description = "VIP 过期时间")
private String vipExpiry;
}

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.model.vo.user;
import com.baomidou.mybatisplus.annotation.TableField;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@@ -42,4 +43,16 @@ public class KeyboardUserRespVO {
@Schema(description = "token")
private String token;
/**
* 是否是 VIP
*/
@Schema(description = "是否是 VIP")
private Boolean isVip;
/**
* VIP 过期时间
*/
@Schema(description = "VIP 过期时间")
private Date vipExpiry;
}

View File

@@ -0,0 +1,26 @@
package com.yolo.keyborad.model.vo.wallet;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.math.BigDecimal;
/*
* @author: ziin
* @date: 2025/12/10
*/
@Schema(description = "用户钱包返回对象")
@Data
public class KeyboardUserWalletRespVO {
/**
* 余额
*/
@Schema(description = "余额")
private BigDecimal balance;
/**
* 格式化后的余额显示
*/
@Schema(description = "格式化后的余额显示")
private String balanceDisplay;
}

View File

@@ -0,0 +1,24 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
/**
* 处理苹果购买后的业务逻辑
*/
public interface ApplePurchaseService {
/**
* 基于验签结果处理购买逻辑(订阅 / 内购)
*
* @param userId 用户ID
* @param validationResult 苹果验签结果
*/
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
/**
* 处理苹果服务器续订通知(无收据,仅基于原始交易号和商品信息)
*
* @param notification 通知内容
*/
void processRenewNotification(com.yolo.keyborad.model.dto.AppleServerNotification notification);
}

View File

@@ -1,6 +1,7 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import jakarta.servlet.http.HttpServletRequest;
/**
* Apple相关API
@@ -14,6 +15,7 @@ public interface IAppleService {
* 登录
*
* @param identityToken JWT身份令牌
* @param request HTTP请求
*/
KeyboardUserRespVO login(String identityToken) throws Exception;
KeyboardUserRespVO login(String identityToken, HttpServletRequest request) throws Exception;
}

View File

@@ -0,0 +1,44 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.products.KeyboardProductItemRespVO;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
/**
* 根据主键ID查询商品明细
*
* @param id 商品主键ID
* @return 商品明细(不存在返回 null
*/
KeyboardProductItemRespVO getProductDetailById(Long id);
/**
* 根据 Apple productId 查询商品明细
*
* @param productId 商品 productId
* @return 商品明细(不存在返回 null
*/
KeyboardProductItemRespVO getProductDetailByProductId(String productId);
/**
* 根据 productId 获取商品实体
*/
KeyboardProductItems getProductEntityByProductId(String productId);
/**
* 根据商品类型查询商品列表
* type=all 时返回全部
*
* @param type 商品类型subscription / in-app-purchase / all
* @return 商品列表
*/
List<KeyboardProductItemRespVO> listProductsByType(String type);
}

View File

@@ -0,0 +1,32 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseListRespVO;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseRespVO;
import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/10 19:17
*/
public interface KeyboardThemePurchaseService extends IService<KeyboardThemePurchase>{
/**
* 购买主题
*/
ThemePurchaseRespVO purchaseTheme(Long userId, Long themeId);
/**
* 查询用户购买记录
*/
List<ThemePurchaseListRespVO> getUserPurchaseList(Long userId);
/**
* 查询用户已购买的主题列表
*/
List<KeyboardThemesRespVO> getUserPurchasedThemes(Long userId);
}

View File

@@ -12,11 +12,28 @@ import java.util.List;
public interface KeyboardThemesService extends IService<KeyboardThemes>{
/**
* 按主题风格查询主题列表(未删除且上架)
* 按主题风格查询主题列表(未删除且上架),包含用户购买状态
* @param themeStyle 主题风格
* @param userId 用户ID
* @return 主题列表
*/
List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle);
List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId);
/**
* 查询主题详情
* @param themeId 主题ID
* @param userId 用户ID
* @return 主题详情
*/
KeyboardThemesRespVO getThemeDetail(Long themeId, Long userId);
/**
* 推荐主题列表(按真实下载数量降序)
* @param userId 用户ID
* @return 推荐主题列表
*/
List<KeyboardThemesRespVO> getRecommendedThemes(Long userId);
}

View File

@@ -0,0 +1,23 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserLoginLog;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/11 20:09
*/
public interface KeyboardUserLoginLogService extends IService<KeyboardUserLoginLog>{
/**
* 记录用户登录信息
* @param userId 用户ID
* @param ipAddress IP地址
* @param deviceInfo 设备信息
* @param os 操作系统
* @param platform 平台(iOS/Android)
* @param status 登录状态
*/
void recordLoginLog(Long userId, String ipAddress, String deviceInfo, String os, String platform, String status);
}

View File

@@ -0,0 +1,13 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
public interface KeyboardUserPurchaseRecordsService extends IService<KeyboardUserPurchaseRecords>{
}

View File

@@ -0,0 +1,21 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserThemes;
import com.baomidou.mybatisplus.extension.service.IService;
import java.util.List;
/*
* @author: ziin
* @date: 2025/12/11 13:31
*/
public interface KeyboardUserThemesService extends IService<KeyboardUserThemes>{
/**
* 批量删除用户主题(逻辑删除)
* @param userId 用户ID
* @param themeIds 主题ID列表
*/
void batchDeleteUserThemes(Long userId, List<Long> themeIds);
}

View File

@@ -0,0 +1,19 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.vo.wallet.KeyboardUserWalletRespVO;
import com.baomidou.mybatisplus.extension.service.IService;
/*
* @author: ziin
* @date: 2025/12/10 18:15
*/
public interface KeyboardUserWalletService extends IService<KeyboardUserWallet>{
/**
* 获取用户钱包余额
* @param userId 用户ID
* @return 钱包余额信息
*/
KeyboardUserWalletRespVO getWalletBalance(Long userId);
}

View File

@@ -0,0 +1,21 @@
package com.yolo.keyborad.service;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
import com.baomidou.mybatisplus.extension.service.IService;
import java.math.BigDecimal;
/*
* @author: ziin
* @date: 2025/12/10 18:54
*/
public interface KeyboardWalletTransactionService extends IService<KeyboardWalletTransaction>{
/**
* 创建钱包交易记录
*/
KeyboardWalletTransaction createTransaction(Long userId, Long orderId, BigDecimal amount,
Short type, BigDecimal beforeBalance,
BigDecimal afterBalance, String description);
}

View File

@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import jakarta.servlet.http.HttpServletRequest;
/*
* @author: ziin
@@ -15,7 +16,7 @@ public interface UserService extends IService<KeyboardUser> {
KeyboardUser createUserWithSubjectId(String sub);
KeyboardUserRespVO login(UserLoginDTO userLoginDTO);
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
Boolean updateUserInfo(KeyboardUserReq keyboardUser);

View File

@@ -0,0 +1,527 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.model.dto.AppleServerNotification;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.KeyboardProductItemsService;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
import com.yolo.keyborad.service.UserService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.time.format.DateTimeParseException;
import java.util.Date;
import java.util.List;
import java.util.Objects;
/**
* 苹果购买后置处理:订阅续期 / 内购充值 + 记录落库
*/
@Service
@Slf4j
public class ApplePurchaseServiceImpl implements ApplePurchaseService {
@Resource
private KeyboardProductItemsService productItemsService;
@Resource
private KeyboardUserPurchaseRecordsService purchaseRecordsService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardWalletTransactionService walletTransactionService;
@Resource
private UserService userService;
/**
* 处理苹果购买(订阅或内购)
* 主要流程:
* 1. 校验收据有效性
* 2. 幂等性检查,避免重复处理同一笔交易
* 3. 查询商品信息
* 4. 保存购买记录
* 5. 根据商品类型执行对应逻辑(订阅延期或钱包充值)
*
* @param userId 用户ID
* @param validationResult 苹果收据验证结果包含交易ID、商品ID、过期时间等信息
* @throws BusinessException 当收据无效、商品不存在或商品类型未知时抛出
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
// 1. 校验收据有效性
if (validationResult == null || !validationResult.isValid()) {
throw new BusinessException(ErrorCode.RECEIPT_INVALID);
}
// 2. 解析商品ID
String productId = resolveProductId(validationResult.getProductIds());
// 3. 幂等性检查根据交易ID判断该笔交易是否已经处理过
// 防止同一笔购买被重复处理,导致用户多次获得权益
boolean handled = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getTransactionId, validationResult.getTransactionId())
.eq(KeyboardUserPurchaseRecords::getStatus, "PAID")
.exists();
if (handled) {
log.info("Apple purchase already handled, transactionId={}", validationResult.getTransactionId());
return;
}
// 4. 查询商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
if (product == null) {
log.error("Apple purchase not found, productId={}", productId);
throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND);
}
// 5. 构建并保存购买记录到数据库
KeyboardUserPurchaseRecords purchaseRecord = buildPurchaseRecord(userId, validationResult, product);
purchaseRecordsService.save(purchaseRecord);
// 6. 根据商品类型执行相应的业务逻辑
if ("subscription".equalsIgnoreCase(product.getType())) {
// 订阅类商品延长用户VIP有效期
handleSubscription(userId, product, validationResult);
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
// 内购类商品:为用户钱包充值
handleInAppPurchase(userId, product, purchaseRecord.getId());
} else {
// 未知商品类型,抛出异常
log.error("未知商品类型, type={}", product.getType());
throw new BusinessException(ErrorCode.UNKNOWN_PRODUCT_TYPE);
}
}
/**
* 处理苹果订阅续期通知
* 当用户的订阅自动续费时,苹果会发送通知到我们的服务器
*
* @param notification 苹果服务器通知对象,包含续订交易信息
* @throws BusinessException 当参数缺失、商品不存在或更新失败时抛出
*/
@Override
@Transactional(rollbackFor = Exception.class)
public void processRenewNotification(AppleServerNotification notification) {
// 参数校验确保通知对象和原始交易ID不为空
if (notification == null || notification.getOriginalTransactionId() == null) {
throw new BusinessException(ErrorCode.LACK_ORIGIN_TRANSACTION_ID_ERROR);
}
// 根据原始交易ID查询历史购买记录
// 原始交易ID可能对应多条记录首次购买 + 多次续订),取最新的一条
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
.orderByDesc(KeyboardUserPurchaseRecords::getId)
.last("LIMIT 1")
.list();
// 如果找不到匹配的购买记录,记录警告并返回
if (records == null || records.isEmpty()) {
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
return;
}
KeyboardUserPurchaseRecords record = records.get(0);
// 根据商品ID获取商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
// 校验商品是否存在且类型为订阅类型
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
return;
}
// 构建续订购买记录对象
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
renewRecord.setUserId(record.getUserId());
renewRecord.setProductId(product.getProductId());
renewRecord.setPurchaseQuantity(product.getDurationValue());
renewRecord.setPrice(product.getPrice());
renewRecord.setCurrency(product.getCurrency());
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
renewRecord.setPurchaseType(product.getType());
renewRecord.setStatus("PAID"); // 续订状态默认为已支付
renewRecord.setPaymentMethod("APPLE");
renewRecord.setTransactionId(notification.getTransactionId()); // 新的交易ID
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); // 原始交易ID
renewRecord.setProductIds(new String[]{product.getProductId()});
// 解析并设置订阅过期时间
Instant expiresInstant = parseInstant(notification.getExpiresDate());
if (expiresInstant != null) {
renewRecord.setExpiresDate(Date.from(expiresInstant));
}
renewRecord.setEnvironment(notification.getEnvironment());
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
// 保存续订记录到数据库
purchaseRecordsService.save(renewRecord);
// 延长用户VIP有效期
extendVip(record.getUserId().longValue(), product, expiresInstant);
}
/**
* 处理订阅类商品的购买
* 为用户延长VIP有效期
*
* @param userId 用户ID
* @param product 商品信息,包含订阅时长等配置
* @param validationResult 苹果收据验证结果(当前方法未直接使用,但保留以便扩展)
* @throws BusinessException 当用户不存在或更新失败时抛出
*/
private void handleSubscription(Long userId, KeyboardProductItems product, AppleReceiptValidationResult validationResult) {
// 1. 查询用户信息
KeyboardUser user = userService.getById(userId);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
// 2. 确定VIP延期的基准时间
// 如果用户当前VIP未过期则基于当前过期时间延长否则基于当前时间延长
Instant base = resolveBaseExpiry(user.getVipExpiry());
// 3. 计算新的VIP过期时间
// 根据商品配置的时长(天数)进行延期
Instant newExpiry = base.plus(resolveDurationDays(product), ChronoUnit.DAYS);
// 4. 更新用户VIP状态
user.setIsVip(true);
user.setVipExpiry(Date.from(newExpiry));
// 5. 保存用户信息到数据库
boolean updated = userService.updateById(user);
if (!updated) {
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
}
// 6. 记录日志
log.info("Extend VIP for user {} to {}", userId, newExpiry);
}
/**
* 处理内购类商品的购买
* 为用户钱包充值相应的额度
*
* @param userId 用户ID
* @param product 商品信息,包含充值额度等配置
* @param purchaseRecordId 购买记录ID用于关联钱包交易记录
* @throws BusinessException 当商品额度未配置或钱包操作失败时抛出
*/
private void handleInAppPurchase(Long userId, KeyboardProductItems product, Integer purchaseRecordId) {
// 1. 查询用户钱包信息
KeyboardUserWallet wallet = walletService.lambdaQuery()
.eq(KeyboardUserWallet::getUserId, userId)
.one();
// 2. 如果用户钱包不存在,创建新钱包
if (wallet == null) {
wallet = new KeyboardUserWallet();
wallet.setUserId(userId);
wallet.setBalance(BigDecimal.ZERO);
wallet.setStatus((short) 1); // 状态1-正常
wallet.setVersion(0); // 乐观锁版本号
wallet.setCreatedAt(new Date());
}
// 3. 解析商品的充值额度
BigDecimal credit = resolveCreditAmount(product);
// 4. 校验充值额度是否有效
if (credit.compareTo(BigDecimal.ZERO) <= 0) {
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
}
// 5. 计算充值前后的余额
BigDecimal before = wallet.getBalance() == null ? BigDecimal.ZERO : wallet.getBalance();
BigDecimal after = before.add(credit);
// 6. 更新钱包余额
wallet.setBalance(after);
wallet.setUpdatedAt(new Date());
walletService.saveOrUpdate(wallet);
// 7. 创建钱包交易记录,用于财务对账和历史查询
KeyboardWalletTransaction tx = walletTransactionService.createTransaction(
userId,
purchaseRecordId == null ? null : purchaseRecordId.longValue(),
credit,
(short) 2, // 交易类型2-苹果内购充值
before,
after,
"Apple 充值: " + product.getProductId()
);
// 8. 记录充值成功日志
log.info("Wallet recharge success, user={}, credit={}, txId={}", userId, credit, tx.getId());
}
/**
* 构建购买记录对象
* 将苹果收据验证结果和商品信息转换为购买记录实体
*
* @param userId 用户ID
* @param validationResult 苹果收据验证结果,包含交易信息
* @param product 商品信息,包含商品配置和定价
* @return 构建完成的购买记录对象
*/
private KeyboardUserPurchaseRecords buildPurchaseRecord(Long userId, AppleReceiptValidationResult validationResult, KeyboardProductItems product) {
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
// 设置用户ID转换为Integer类型
record.setUserId(userId.intValue());
// 设置商品相关信息
record.setProductId(product.getProductId());
record.setPurchaseQuantity(product.getDurationValue()); // 购买数量/时长
record.setPrice(product.getPrice()); // 商品价格
record.setCurrency(product.getCurrency()); // 货币类型
record.setPurchaseType(product.getType()); // 商品类型subscription 或 in-app-purchase
// 设置购买时间,如果验证结果中没有则使用当前时间
record.setPurchaseTime(Date.from(Objects.requireNonNullElseGet(validationResult.getPurchaseDate(), Instant::now)));
// 设置交易状态和支付方式
record.setStatus("PAID"); // 状态:已支付
record.setPaymentMethod("APPLE"); // 支付方式:苹果支付
// 设置苹果交易相关信息
record.setTransactionId(validationResult.getTransactionId()); // 当前交易ID
record.setOriginalTransactionId(validationResult.getOriginalTransactionId()); // 原始交易ID用于续订关联
record.setProductIds(new String[]{String.join(",", validationResult.getProductIds())}); // 商品ID列表逗号分隔
// 设置订阅过期时间(仅订阅类商品有此字段)
if (validationResult.getExpiresDate() != null) {
record.setExpiresDate(Date.from(validationResult.getExpiresDate()));
}
// 设置交易环境PRODUCTION生产环境或 SANDBOX沙盒环境
record.setEnvironment(validationResult.getEnvironment() == null ? null : validationResult.getEnvironment().name());
// 设置购买日期与purchaseTime可能有细微差别保留原始数据
record.setPurchaseDate(validationResult.getPurchaseDate() == null ? null : Date.from(validationResult.getPurchaseDate()));
return record;
}
/**
* 延长用户VIP有效期
* 支持两种延期策略:
* 1. 如果提供了目标过期时间targetExpiry则直接使用该时间
* 2. 如果未提供目标时间则基于用户当前VIP过期时间延长指定时长
*
* @param userId 用户ID
* @param product 商品信息,包含订阅时长配置
* @param targetExpiry 目标过期时间(可选),通常来自苹果续订通知中的过期时间
* @throws BusinessException 当用户不存在或更新失败时抛出
*/
private void extendVip(Long userId, KeyboardProductItems product, Instant targetExpiry) {
// 1. 查询用户信息
KeyboardUser user = userService.getById(userId);
if (user == null) {
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
}
// 2. 解析商品配置的订阅时长(天数)
long durationDays = resolveDurationDays(product);
// 3. 确定VIP延期的基准时间
// 如果用户当前VIP未过期则基于当前过期时间延长否则基于当前时间延长
Instant base = resolveBaseExpiry(user.getVipExpiry());
// 4. 计算新的VIP过期时间
// 优先使用目标时间(如续订通知中的过期时间),否则基于基准时间加上订阅时长
Instant newExpiry = targetExpiry != null ? targetExpiry : base.plus(durationDays, ChronoUnit.DAYS);
// 5. 防御性检查:如果计算出的过期时间早于当前时间(异常情况),则基于当前时间重新计算
// 这种情况可能发生在时间不同步或数据异常时确保VIP不会立即过期
if (newExpiry.isBefore(Instant.now())) {
newExpiry = Instant.now().plus(durationDays, ChronoUnit.DAYS);
}
// 6. 更新用户VIP状态
user.setIsVip(true); // 设置为VIP用户
user.setVipExpiry(Date.from(newExpiry)); // 更新VIP过期时间
// 7. 保存用户信息到数据库
boolean updated = userService.updateById(user);
if (!updated) {
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
}
// 8. 记录延期成功日志
log.info("Extend VIP by notification, user {} to {}", userId, newExpiry);
}
/**
* 解析商品ID列表提取第一个商品ID
*
* @param productIds 商品ID列表通常来自苹果收据验证结果
* @return 第一个商品ID
* @throws BusinessException 当商品ID列表为空时抛出
*/
private String resolveProductId(List<String> productIds) {
// 校验商品ID列表不为空
if (productIds == null || productIds.isEmpty()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "productId 缺失");
}
// 返回列表中的第一个商品ID
return productIds.get(0);
}
/**
* 解析VIP延期的基准时间
* 如果用户当前VIP未过期则基于当前过期时间延长否则基于当前时间延长
*
* @param currentExpiry 用户当前的VIP过期时间
* @return VIP延期的基准时间点
*/
private Instant resolveBaseExpiry(Date currentExpiry) {
// 如果当前过期时间为空,返回当前时间作为基准
if (currentExpiry == null) {
return Instant.now();
}
// 将Date转换为Instant
Instant current = currentExpiry.toInstant();
// 如果VIP未过期使用当前过期时间作为基准否则使用当前时间
return current.isAfter(Instant.now()) ? current : Instant.now();
}
/**
* 解析商品的订阅时长(转换为天数)
* 支持多种配置方式:
* 1. 直接配置的durationDays字段
* 2. 通过durationValue和durationUnit组合计算支持天/月单位)
*
* @param product 商品信息,包含时长相关配置
* @return 订阅时长天数如果无法解析则返回0
*/
private long resolveDurationDays(KeyboardProductItems product) {
// 优先使用直接配置的天数字段
if (product.getDurationDays() != null && product.getDurationDays() > 0) {
return product.getDurationDays();
}
// 解析时长数值和单位
Integer value = product.getDurationValue();
String unit = product.getDurationUnit() == null ? "" : product.getDurationUnit().toLowerCase();
// 校验时长数值是否有效
if (value == null || value <= 0) {
return 0;
}
// 根据单位进行转换
// 如果单位是"天",直接返回数值
if (unit.contains("day") || unit.contains("")) {
return value;
}
// 如果单位是"月"按30天/月进行转换
if (unit.contains("month") || unit.contains("")) {
return (long) value * 30;
}
// 默认返回原始数值(当单位未知时)
return value;
}
/**
* 解析商品的充值额度
* 优先从商品名称中提取数值如果提取失败则使用durationValue字段
*
* @param product 商品信息包含name和durationValue字段
* @return 充值额度如果无法解析则返回0
*/
private BigDecimal resolveCreditAmount(KeyboardProductItems product) {
// 优先尝试从商品名称中解析数值(如"100金币"中的100
BigDecimal fromName = parseNumber(product.getName());
if (fromName != null) {
return fromName;
}
// 如果名称中没有数值使用durationValue字段作为备选
if (product.getDurationValue() != null) {
return BigDecimal.valueOf(product.getDurationValue());
}
// 如果都没有返回0
return BigDecimal.ZERO;
}
/**
* 从字符串中提取数值
* 通过正则表达式移除所有非数字和小数点字符然后转换为BigDecimal
*
* @param raw 原始字符串,可能包含数字和其他字符(如"100金币"
* @return 提取出的数值如果解析失败则返回null
*/
private BigDecimal parseNumber(String raw) {
// 空值检查
if (raw == null) {
return null;
}
try {
// 移除所有非数字和非小数点字符然后转换为BigDecimal
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
} catch (Exception e) {
// 解析失败时返回null如字符串中完全没有数字
return null;
}
}
/**
* 解析ISO 8601格式的时间字符串为Instant对象
*
* @param iso ISO 8601格式的时间字符串如"2024-01-01T00:00:00Z"
* @return 解析后的Instant对象解析失败或输入为空时返回null
*/
private Instant parseInstant(String iso) {
// 空值或空白字符串检查
if (iso == null || iso.isBlank()) {
return null;
}
try {
// 解析ISO 8601格式的时间字符串
return Instant.parse(iso);
} catch (DateTimeParseException e) {
// 解析失败时记录警告日志并返回null
log.warn("Failed to parse expiresDate: {}", iso);
return null;
}
}
/**
* 将Instant对象转换为Date对象
*
* @param instant Instant时间对象
* @return 转换后的Date对象输入为null时返回null
*/
private Date toDate(Instant instant) {
return instant == null ? null : Date.from(instant);
}
}

View File

@@ -11,11 +11,11 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.IAppleService;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
import com.yolo.keyborad.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import io.jsonwebtoken.*;
import jakarta.annotation.Resource;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
@@ -40,13 +40,17 @@ public class AppleServiceImpl implements IAppleService {
@Resource
private UserService userService;
@Resource
private KeyboardUserLoginLogService loginLogService;
/**
* 登录
*
* @param identityToken JWT身份令牌
* @param request HTTP请求
*/
@Override
public KeyboardUserRespVO login(String identityToken) throws Exception {
public KeyboardUserRespVO login(String identityToken, HttpServletRequest request) throws Exception {
// 1. 清理一下 token防止前后多了引号/空格
identityToken = identityToken.trim();
@@ -88,13 +92,51 @@ public class AppleServiceImpl implements IAppleService {
// 返回用户标识符
if (result) {
KeyboardUser user = userService.selectUserWithSubjectId(sub);
boolean isNewUser = false;
if (user == null) {
KeyboardUser newUser = userService.createUserWithSubjectId(sub);
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(newUser, KeyboardUserRespVO.class);
StpUtil.login(newUser.getId());
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(newUser.getId()));
return keyboardUserRespVO;
user = userService.createUserWithSubjectId(sub);
isNewUser = true;
}
// 记录登录日志
try {
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
String platform = "Unknown";
String os = "Unknown";
if (userAgent != null) {
if (userAgent.contains("iOS")) {
platform = "iOS";
} else if (userAgent.contains("Android")) {
platform = "Android";
}
if (userAgent.contains("Windows")) {
os = "Windows";
} else if (userAgent.contains("Mac OS")) {
os = "Mac OS";
} else if (userAgent.contains("Linux")) {
os = "Linux";
} else if (userAgent.contains("iOS")) {
os = "iOS";
} else if (userAgent.contains("Android")) {
os = "Android";
}
}
loginLogService.recordLoginLog(
user.getId(),
ipAddress,
userAgent,
os,
platform,
isNewUser ? "APPLE_NEW_USER" : "SUCCESS"
);
} catch (Exception e) {
log.error("记录Apple登录日志失败", e);
}
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
StpUtil.login(user.getId());
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));

View File

@@ -0,0 +1,99 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.yolo.keyborad.model.vo.products.KeyboardProductItemRespVO;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardProductItems;
import com.yolo.keyborad.mapper.KeyboardProductItemsMapper;
import com.yolo.keyborad.service.KeyboardProductItemsService;
/*
* @author: ziin
* @date: 2025/12/12 13:44
*/
@Service
public class KeyboardProductItemsServiceImpl extends ServiceImpl<KeyboardProductItemsMapper, KeyboardProductItems> implements KeyboardProductItemsService{
/**
* 根据ID获取产品详情
*
* @param id 产品ID
* @return 产品详情响应对象如果ID为空或未找到产品则返回null
*/
@Override
public KeyboardProductItemRespVO getProductDetailById(Long id) {
// 参数校验ID不能为空
if (id == null) {
return null;
}
// 根据ID查询产品信息
KeyboardProductItems item = this.getById(id);
// 将实体对象转换为响应VO对象并返回
return item == null ? null : BeanUtil.copyProperties(item, KeyboardProductItemRespVO.class);
}
/**
* 根据产品ID获取产品详情
*
* @param productId 产品ID
* @return 产品详情响应对象如果产品ID为空或未找到产品则返回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);
}
@Override
public KeyboardProductItems getProductEntityByProductId(String productId) {
if (productId == null || productId.isBlank()) {
return null;
}
return this.lambdaQuery()
.eq(KeyboardProductItems::getProductId, productId)
.one();
}
/**
* 根据类型获取产品列表
*
* @param type 产品类型如果为null、空字符串或"all"则查询所有产品
* @return 产品详情响应列表按ID升序排列
*/
@Override
public java.util.List<KeyboardProductItemRespVO> listProductsByType(String type) {
// 创建Lambda查询构造器
var query = this.lambdaQuery();
// 如果类型参数有效且不是"all",则添加类型过滤条件
if (type != null && !type.isBlank() && !"all".equalsIgnoreCase(type)) {
query.eq(KeyboardProductItems::getType, type);
}
// 执行查询按ID升序排列
java.util.List<KeyboardProductItems> items = query
.orderByAsc(KeyboardProductItems::getId)
.list();
// 将实体对象转换为响应VO对象并返回
return items.stream()
.map(i -> BeanUtil.copyProperties(i, KeyboardProductItemRespVO.class))
.toList();
}
}

View File

@@ -0,0 +1,245 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.entity.KeyboardThemes;
import com.yolo.keyborad.model.entity.KeyboardUserThemes;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseListRespVO;
import com.yolo.keyborad.model.vo.purchase.ThemePurchaseRespVO;
import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
import com.yolo.keyborad.service.KeyboardThemesService;
import com.yolo.keyborad.service.KeyboardUserThemesService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import java.util.UUID;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
import com.yolo.keyborad.mapper.KeyboardThemePurchaseMapper;
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
/*
* @author: ziin
* @date: 2025/12/10 19:17
*/
@Service
public class KeyboardThemePurchaseServiceImpl extends ServiceImpl<KeyboardThemePurchaseMapper, KeyboardThemePurchase> implements KeyboardThemePurchaseService{
@Resource
private KeyboardThemesService themesService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardWalletTransactionService transactionService;
@Resource
private KeyboardUserThemesService userThemesService;
@Override
@Transactional(rollbackFor = Exception.class)
public ThemePurchaseRespVO purchaseTheme(Long userId, Long themeId) {
// 1. 验证主题是否存在且可购买
// 从数据库获取主题信息
KeyboardThemes theme = themesService.getById(themeId);
// 检查主题是否存在或已被删除
if (theme == null || theme.getDeleted()) {
throw new BusinessException(ErrorCode.THEME_NOT_FOUND);
}
// 检查主题状态是否可用(上架状态)
if (!theme.getThemeStatus()) {
throw new BusinessException(ErrorCode.THEME_NOT_AVAILABLE);
}
// 2. 检查是否已购买
// 查询用户是否已经购买过该主题支付状态为1表示已支付
Long purchaseCount = this.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId)
.eq(KeyboardThemePurchase::getThemeId, themeId)
.eq(KeyboardThemePurchase::getPayStatus, (short) 1)
.count();
// 如果已购买,抛出异常
if (purchaseCount > 0) {
throw new BusinessException(ErrorCode.THEME_ALREADY_PURCHASED);
}
// 3. 获取用户钱包
// 查询用户钱包信息
KeyboardUserWallet wallet = walletService.lambdaQuery()
.eq(KeyboardUserWallet::getUserId, userId)
.one();
// 如果钱包不存在,抛出余额不足异常
if (wallet == null) {
throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE);
}
// 4. 检查余额是否充足
// 获取主题价格
BigDecimal themePrice = theme.getThemePrice();
// 比较钱包余额和主题价格,余额不足则抛出异常
if (wallet.getBalance().compareTo(themePrice) < 0) {
throw new BusinessException(ErrorCode.INSUFFICIENT_BALANCE);
}
// 5. 扣减余额(使用乐观锁)
// 记录扣款前余额
BigDecimal beforeBalance = wallet.getBalance();
// 计算扣款后余额
BigDecimal afterBalance = beforeBalance.subtract(themePrice);
// 更新钱包余额
wallet.setBalance(afterBalance);
wallet.setUpdatedAt(new Date());
// 执行更新操作,乐观锁机制确保并发安全
boolean updateSuccess = walletService.updateById(wallet);
if (!updateSuccess) {
throw new BusinessException(ErrorCode.OPERATION_ERROR);
}
// 6. 创建购买记录
// 生成唯一订单号ORDER_时间戳_8位UUID
String orderNo = "ORDER_" + System.currentTimeMillis() + "_" + UUID.randomUUID().toString().substring(0, 8);
// 构建购买记录对象
KeyboardThemePurchase purchase = new KeyboardThemePurchase();
purchase.setOrderNo(orderNo); // 订单号
purchase.setUserId(userId); // 用户ID
purchase.setThemeId(themeId); // 主题ID
purchase.setCostPoints(themePrice); // 消费积分
purchase.setPaidPoints(themePrice); // 实付积分
purchase.setPayStatus((short) 1); // 支付状态1-已支付
purchase.setCreatedAt(new Date()); // 创建时间
purchase.setPaidAt(new Date()); // 支付时间
purchase.setUpdatedAt(new Date()); // 更新时间
// 保存购买记录到数据库
this.save(purchase);
// 7. 创建钱包交易记录
// 调用交易服务创建一条钱包交易记录
KeyboardWalletTransaction transaction = transactionService.createTransaction(
userId, // 用户ID
purchase.getId(), // 关联的购买记录ID
themePrice.negate(), // 交易金额(负数表示支出)
(short) 1, // 交易类型1-购买主题
beforeBalance, // 交易前余额
afterBalance, // 交易后余额
"购买主题: " + theme.getThemeName() // 交易备注
);
// 8. 更新购买记录的交易ID
// 将交易记录ID关联到购买记录中
purchase.setWalletTxId(transaction.getId());
this.updateById(purchase);
// 9. 添加到用户主题表
KeyboardUserThemes userTheme = new KeyboardUserThemes();
userTheme.setUserId(userId);
userTheme.setThemeId(themeId);
userTheme.setCreatedAt(new Date());
userTheme.setViewDeleted(false);
userThemesService.save(userTheme);
// 10. 构造返回结果
// 创建响应对象,封装购买结果信息
ThemePurchaseRespVO respVO = new ThemePurchaseRespVO();
respVO.setOrderNo(orderNo); // 订单号
respVO.setThemeId(themeId); // 主题ID
respVO.setPaidAmount(themePrice); // 支付金额
respVO.setRemainingBalance(afterBalance); // 剩余余额
return respVO;
}
@Override
/**
* 获取用户的主题购买记录列表
*
* @param userId 用户ID
* @return 购买记录列表
*/
public List<ThemePurchaseListRespVO> getUserPurchaseList(Long userId) {
// 1. 查询用户的所有购买记录,按创建时间倒序排列
List<KeyboardThemePurchase> purchases = this.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId) // 根据用户ID筛选
.orderByDesc(KeyboardThemePurchase::getCreatedAt) // 按创建时间倒序
.list();
// 2. 将购买记录转换为响应VO对象
return purchases.stream().map(purchase -> {
// 创建响应VO对象
ThemePurchaseListRespVO vo = new ThemePurchaseListRespVO();
// 设置订单基本信息
vo.setOrderNo(purchase.getOrderNo()); // 订单号
vo.setThemeId(purchase.getThemeId()); // 主题ID
vo.setPaidAmount(purchase.getPaidPoints()); // 支付金额
vo.setPayStatus(purchase.getPayStatus()); // 支付状态
vo.setCreatedAt(purchase.getCreatedAt()); // 创建时间
vo.setPaidAt(purchase.getPaidAt()); // 支付时间
// 3. 获取主题详情,填充主题名称
KeyboardThemes theme = themesService.getById(purchase.getThemeId());
if (theme != null) {
vo.setThemeName(theme.getThemeName()); // 主题名称
}
return vo;
}).collect(java.util.stream.Collectors.toList());
}
@Override
/**
* 获取用户已购买的主题列表
*
* @param userId 用户ID
* @return 用户已购买的主题详情列表
*/
public List<KeyboardThemesRespVO> getUserPurchasedThemes(Long userId) {
// 1. 查询用户所有已支付的主题ID列表
List<Long> themeIds = this.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId) // 根据用户ID筛选
.eq(KeyboardThemePurchase::getPayStatus, (short) 1) // 支付状态为1已支付
.list()
.stream()
.map(KeyboardThemePurchase::getThemeId) // 提取主题ID
.distinct() // 去重
.collect(java.util.stream.Collectors.toList());
// 2. 如果没有购买记录,返回空列表
if (themeIds.isEmpty()) {
return java.util.Collections.emptyList();
}
// 3. 根据主题ID列表查询主题详情并转换为响应VO对象
return themesService.lambdaQuery()
.in(KeyboardThemes::getId, themeIds) // 根据主题ID列表查询
.eq(KeyboardThemes::getDeleted, false) // 排除已删除的主题
.list()
.stream()
.map(theme -> {
// 创建响应VO对象并填充主题信息
KeyboardThemesRespVO vo = new KeyboardThemesRespVO();
vo.setId(theme.getId()); // 主题ID
vo.setThemePreviewImageUrl(theme.getThemePreviewImageUrl()); // 主题预览图片
vo.setThemeName(theme.getThemeName()); // 主题名称
vo.setThemePrice(theme.getThemePrice()); // 主题价格
vo.setThemeTag(theme.getThemeTag()); // 主题标签
vo.setThemeDownload(theme.getThemeDownload()); // 下载地址
vo.setThemeStyle(theme.getThemeStyle()); // 主题风格
vo.setThemePurchasesNumber(theme.getThemePurchasesNumber()); // 购买次数
vo.setSort(theme.getSort()); // 排序值
vo.setIsFree(theme.getIsFree()); // 是否免费
return vo;
}).collect(java.util.stream.Collectors.toList());
}
}

View File

@@ -1,9 +1,16 @@
package com.yolo.keyborad.service.impl;
import cn.hutool.core.bean.BeanUtil;
import com.baomidou.mybatisplus.extension.conditions.query.LambdaQueryChainWrapper;
import com.yolo.keyborad.model.entity.KeyboardThemePurchase;
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardThemesMapper;
import com.yolo.keyborad.model.entity.KeyboardThemes;
@@ -17,21 +24,137 @@ import com.yolo.keyborad.service.KeyboardThemesService;
@Service
public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper, KeyboardThemes> implements KeyboardThemesService {
@Resource
@Lazy // 延迟加载,打破循环依赖
private KeyboardThemePurchaseService purchaseService;
/**
* 根据风格查询主题列表
* <p>查询规则:</p>
* <ul>
* <li>当themeStyle为9999时查询所有主题并按排序字段升序排列</li>
* <li>其他情况下,查询指定风格的主题</li>
* <li>查询结果均过滤已删除和未启用的主题</li>
* <li>返回的主题列表包含用户的购买状态</li>
* </ul>
*
* @param themeStyle 主题风格ID9999表示查询所有风格
* @param userId 用户ID用于判断主题购买状态
* @return 主题列表,包含主题详情和购买状态
*/
@Override
public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle) {
public List<KeyboardThemesRespVO> selectThemesByStyle(Long themeStyle, Long userId) {
// 根据风格参数查询主题列表
List<KeyboardThemes> themesList;
if (themeStyle == 9999) {
List<KeyboardThemes> themesList = this.lambdaQuery()
// 查询所有主题,按排序字段升序
themesList = this.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.orderByAsc(KeyboardThemes::getSort)
.list();
return BeanUtil.copyToList(themesList, KeyboardThemesRespVO.class);
}
List<KeyboardThemes> themesList = this.lambdaQuery()
} else {
// 查询指定风格的主题
themesList = this.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.eq(KeyboardThemes::getThemeStyle, themeStyle)
.list();
return BeanUtil.copyToList(themesList, KeyboardThemesRespVO.class);
}
// 查询用户已购买的主题ID集合
Set<Long> purchasedThemeIds = purchaseService.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId)
.eq(KeyboardThemePurchase::getPayStatus, (short) 1)
.list()
.stream()
.map(KeyboardThemePurchase::getThemeId)
.collect(Collectors.toSet());
// 转换为VO并设置购买状态
return themesList.stream().map(theme -> {
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
vo.setIsPurchased(purchasedThemeIds.contains(theme.getId()));
return vo;
}).collect(Collectors.toList());
}
/**
* 获取主题详情
* <p>查询指定ID的主题详情并返回用户对该主题的购买状态</p>
*
* @param themeId 主题ID
* @param userId 用户ID用于判断购买状态
* @return 主题详情VO包含主题信息和购买状态如果主题不存在或已删除则返回null
*/
@Override
public KeyboardThemesRespVO getThemeDetail(Long themeId, Long userId) {
// 查询主题详情
KeyboardThemes theme = this.lambdaQuery()
.eq(KeyboardThemes::getId, themeId)
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.one();
// 主题不存在或已删除返回null
if (theme == null) {
return null;
}
// 查询用户是否购买该主题
boolean isPurchased = purchaseService.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId)
.eq(KeyboardThemePurchase::getThemeId, themeId)
.eq(KeyboardThemePurchase::getPayStatus, (short) 1)
.exists();
// 转换为VO并设置购买状态
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
vo.setIsPurchased(isPurchased);
return vo;
}
@Override
/*
获取推荐主题列表
<p>推荐规则根据真实下载量降序排序排除用户已购买的主题最多返回8个主题</p>
@param userId 用户ID
* @return 推荐主题列表,包含主题详情和购买状态(推荐列表中的主题购买状态均为未购买)
*/
public List<KeyboardThemesRespVO> getRecommendedThemes(Long userId) {
// 查询用户已购买的主题ID集合
Set<Long> purchasedThemeIds = purchaseService.lambdaQuery()
.eq(KeyboardThemePurchase::getUserId, userId)
.eq(KeyboardThemePurchase::getPayStatus, (short) 1)
.list()
.stream()
.map(KeyboardThemePurchase::getThemeId)
.collect(Collectors.toSet());
// 构建查询器
LambdaQueryChainWrapper<KeyboardThemes> queryWrapper = this.lambdaQuery()
.eq(KeyboardThemes::getDeleted, false)
.eq(KeyboardThemes::getThemeStatus, true)
.orderByDesc(KeyboardThemes::getRealDownloadCount);
// 如果有已购买的主题,排除它们
if (!purchasedThemeIds.isEmpty()) {
queryWrapper.notIn(KeyboardThemes::getId, purchasedThemeIds);
}
// 查询推荐主题列表限制8条
List<KeyboardThemes> themesList = queryWrapper.list();
// 只取前8条数据
return themesList.stream()
.limit(8)
.map(theme -> {
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
// 推荐列表中的主题均为未购买状态
vo.setIsPurchased(false);
return vo;
}).collect(Collectors.toList());
}
}

View File

@@ -0,0 +1,33 @@
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.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserLoginLogMapper;
import com.yolo.keyborad.model.entity.KeyboardUserLoginLog;
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
/*
* @author: ziin
* @date: 2025/12/11 20:09
*/
@Service
public class KeyboardUserLoginLogServiceImpl extends ServiceImpl<KeyboardUserLoginLogMapper, KeyboardUserLoginLog> implements KeyboardUserLoginLogService{
@Override
public void recordLoginLog(Long userId, String ipAddress, String deviceInfo, String os, String platform, String status) {
KeyboardUserLoginLog loginLog = new KeyboardUserLoginLog();
loginLog.setUserId(userId);
loginLog.setIpAddress(ipAddress);
loginLog.setDeviceInfo(deviceInfo);
loginLog.setOs(os);
loginLog.setPlatform(platform);
loginLog.setStatus(status);
loginLog.setLoginTime(new java.util.Date());
loginLog.setCreatedAt(new java.util.Date());
loginLog.setUpdatedAt(new java.util.Date());
this.save(loginLog);
}
}

View File

@@ -0,0 +1,18 @@
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.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper;
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
/*
* @author: ziin
* @date: 2025/12/12 15:16
*/
@Service
public class KeyboardUserPurchaseRecordsServiceImpl extends ServiceImpl<KeyboardUserPurchaseRecordsMapper, KeyboardUserPurchaseRecords> implements KeyboardUserPurchaseRecordsService{
}

View File

@@ -0,0 +1,32 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.model.entity.KeyboardUserThemes;
import com.yolo.keyborad.mapper.KeyboardUserThemesMapper;
import com.yolo.keyborad.service.KeyboardUserThemesService;
/*
* @author: ziin
* @date: 2025/12/11 13:31
*/
@Service
public class KeyboardUserThemesServiceImpl extends ServiceImpl<KeyboardUserThemesMapper, KeyboardUserThemes> implements KeyboardUserThemesService{
/**
* 批量删除用户主题(逻辑删除)
*
* @param userId 用户ID
* @param themeIds 主题ID列表
*/
@Override
public void batchDeleteUserThemes(Long userId, List<Long> themeIds) {
this.lambdaUpdate()
.eq(KeyboardUserThemes::getUserId, userId)
.in(KeyboardUserThemes::getThemeId, themeIds)
.set(KeyboardUserThemes::getViewDeleted, true)
.update();
}
}

View File

@@ -0,0 +1,42 @@
package com.yolo.keyborad.service.impl;
import com.yolo.keyborad.model.vo.wallet.KeyboardUserWalletRespVO;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardUserWalletMapper;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import java.math.BigDecimal;
import java.math.RoundingMode;
/*
* @author: ziin
* @date: 2025/12/10 18:15
*/
@Service
@Slf4j
public class KeyboardUserWalletServiceImpl extends ServiceImpl<KeyboardUserWalletMapper, KeyboardUserWallet> implements KeyboardUserWalletService{
@Override
public KeyboardUserWalletRespVO getWalletBalance(Long userId) {
KeyboardUserWallet wallet = this.lambdaQuery()
.eq(KeyboardUserWallet::getUserId, userId)
.one();
KeyboardUserWalletRespVO respVO = new KeyboardUserWalletRespVO();
BigDecimal balance = (wallet == null) ? BigDecimal.ZERO : wallet.getBalance();
respVO.setBalance(balance);
respVO.setBalanceDisplay(formatBalance(balance));
return respVO;
}
private String formatBalance(BigDecimal balance) {
if (balance.compareTo(new BigDecimal("10000")) >= 0) {
BigDecimal kValue = balance.divide(new BigDecimal("1000"), 2, RoundingMode.HALF_UP);
return kValue.stripTrailingZeros().toPlainString() + "K";
}
return balance.setScale(2, RoundingMode.HALF_UP).toPlainString();
}
}

View File

@@ -0,0 +1,36 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.math.BigDecimal;
import java.util.Date;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.KeyboardWalletTransactionMapper;
import com.yolo.keyborad.model.entity.KeyboardWalletTransaction;
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
/*
* @author: ziin
* @date: 2025/12/10 18:54
*/
@Service
public class KeyboardWalletTransactionServiceImpl extends ServiceImpl<KeyboardWalletTransactionMapper, KeyboardWalletTransaction> implements KeyboardWalletTransactionService{
@Override
public KeyboardWalletTransaction createTransaction(Long userId, Long orderId, BigDecimal amount,
Short type, BigDecimal beforeBalance,
BigDecimal afterBalance, String description) {
KeyboardWalletTransaction transaction = new KeyboardWalletTransaction();
transaction.setUserId(userId);
transaction.setOrderId(orderId);
transaction.setAmount(amount);
transaction.setType(type);
transaction.setBeforeBalance(beforeBalance);
transaction.setAfterBalance(afterBalance);
transaction.setDescription(description);
transaction.setCreatedAt(new Date());
this.save(transaction);
return transaction;
}
}

View File

@@ -1,6 +1,7 @@
package com.yolo.keyborad.service.impl;
import com.google.common.primitives.Floats;
import com.google.common.util.concurrent.ListenableFuture;
import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.vo.QdrantSearchItem;
@@ -88,6 +89,13 @@ public class QdrantVectorService {
// }
// }
/**
* 搜索向量(高并发优化版本,避免阻塞线程池)
*
* @param userVector 用户输入的向量
* @param limit 返回结果数量限制
* @return 搜索结果列表
*/
public List<QdrantSearchItem> searchPoint(float[] userVector, int limit) {
try {
Points.QueryPoints query = Points.QueryPoints.newBuilder()
@@ -97,10 +105,16 @@ public class QdrantVectorService {
.setWithPayload(enable(true)) // ★ 带上 payload
.build();
List<Points.BatchResult> batchResults = qdrantClient.queryBatchAsync(
// 使用 ListenableFuture添加超时保护
ListenableFuture<List<Points.BatchResult>> future = qdrantClient.queryBatchAsync(
COLLECTION_NAME,
List.of(query)
).get();
);
// 设置超时时间10秒避免无限等待
List<Points.BatchResult> batchResults = future.get(
10, java.util.concurrent.TimeUnit.SECONDS
);
Points.BatchResult batchResult = batchResults.get(0);
// 3. 把 Protobuf 的 ScoredPoint 转成你的 DTO
@@ -129,10 +143,17 @@ public class QdrantVectorService {
})
.toList();
} catch (InterruptedException | ExecutionException e) {
log.error("search point 失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR);
} catch (InterruptedException e) {
// 恢复中断状态,避免吞掉中断信号
Thread.currentThread().interrupt();
log.error("search point 被中断", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "向量搜索被中断");
} catch (java.util.concurrent.TimeoutException e) {
log.error("search point 超时", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "向量搜索超时,请稍后重试");
} catch (ExecutionException e) {
log.error("search point 执行失败", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "向量搜索执行失败");
}
}

View File

@@ -12,20 +12,23 @@ import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.mapper.KeyboardUserMapper;
import com.yolo.keyborad.model.dto.user.*;
import com.yolo.keyborad.model.entity.KeyboardUser;
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
import com.yolo.keyborad.service.KeyboardCharacterService;
import com.yolo.keyborad.service.KeyboardUserLoginLogService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.UserService;
import jakarta.servlet.http.HttpServletRequest;
import com.yolo.keyborad.utils.RedisUtil;
import com.yolo.keyborad.utils.SendMailUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.checkerframework.checker.units.qual.K;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.Date;
import java.util.concurrent.TimeUnit;
/*
@@ -51,6 +54,12 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource
private KeyboardCharacterService keyboardCharacterService;
@Resource
private KeyboardUserWalletService walletService;
@Resource
private KeyboardUserLoginLogService loginLogService;
@Override
public KeyboardUser selectUserWithSubjectId(String sub) {
return keyboardUserMapper.selectOne(
@@ -67,11 +76,22 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
// 初始化用户钱包
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId());
wallet.setBalance(BigDecimal.ZERO);
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
return keyboardUser;
}
@Override
public KeyboardUserRespVO login(UserLoginDTO userLoginDTO) {
public KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request) {
KeyboardUser keyboardUser = keyboardUserMapper.selectOne(
new LambdaQueryWrapper<KeyboardUser>()
.eq(KeyboardUser::getEmail, userLoginDTO.getMail())
@@ -83,6 +103,46 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
}
StpUtil.login(keyboardUser.getId());
// 记录登录日志
try {
String ipAddress = request.getRemoteAddr();
String userAgent = request.getHeader("User-Agent");
String platform = "Unknown";
String os = "Unknown";
if (userAgent != null) {
if (userAgent.contains("iOS")) {
platform = "iOS";
} else if (userAgent.contains("Android")) {
platform = "Android";
}
if (userAgent.contains("Windows")) {
os = "Windows";
} else if (userAgent.contains("Mac OS")) {
os = "Mac OS";
} else if (userAgent.contains("Linux")) {
os = "Linux";
} else if (userAgent.contains("iOS")) {
os = "iOS";
} else if (userAgent.contains("Android")) {
os = "Android";
}
}
loginLogService.recordLoginLog(
keyboardUser.getId(),
ipAddress,
userAgent,
os,
platform,
"SUCCESS"
);
} catch (Exception e) {
log.error("记录登录日志失败", e);
}
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
return keyboardUserRespVO;
@@ -140,6 +200,16 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
int insertCount = keyboardUserMapper.insert(keyboardUser);
if (insertCount > 0) {
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
// 初始化用户钱包
KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId());
wallet.setBalance(BigDecimal.ZERO);
wallet.setVersion(0);
wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date());
walletService.save(wallet);
}
return insertCount > 0;
}

View File

@@ -0,0 +1,67 @@
// ThemeTagTypeHandler.java
package com.yolo.keyborad.typehandler;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yolo.keyborad.model.entity.ThemeTagItem;
import org.apache.ibatis.type.BaseTypeHandler;
import org.apache.ibatis.type.JdbcType;
import org.apache.ibatis.type.MappedJdbcTypes;
import org.apache.ibatis.type.MappedTypes;
import org.postgresql.util.PGobject;
import java.sql.*;
import java.util.List;
@MappedTypes(List.class)
@MappedJdbcTypes(JdbcType.OTHER) // PostgreSQL jsonb = OTHER
public class ThemeTagTypeHandler extends BaseTypeHandler<List<ThemeTagItem>> {
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
public void setNonNullParameter(PreparedStatement ps, int i,
List<ThemeTagItem> parameter,
JdbcType jdbcType) throws SQLException {
try {
String json = MAPPER.writeValueAsString(parameter);
PGobject pgObject = new PGobject();
pgObject.setType("jsonb");
pgObject.setValue(json);
ps.setObject(i, pgObject);
} catch (Exception e) {
throw new SQLException("Failed to convert themeTag to jsonb", e);
}
}
@Override
public List<ThemeTagItem> getNullableResult(ResultSet rs, String columnName) throws SQLException {
String json = rs.getString(columnName);
return parseJson(json);
}
@Override
public List<ThemeTagItem> getNullableResult(ResultSet rs, int columnIndex) throws SQLException {
String json = rs.getString(columnIndex);
return parseJson(json);
}
@Override
public List<ThemeTagItem> getNullableResult(CallableStatement cs, int columnIndex) throws SQLException {
String json = cs.getString(columnIndex);
return parseJson(json);
}
private List<ThemeTagItem> parseJson(String json) throws SQLException {
if (json == null) {
return null;
}
try {
return MAPPER.readValue(json, new TypeReference<List<ThemeTagItem>>() {});
} catch (Exception e) {
throw new SQLException("Failed to parse jsonb to List<ThemeTagItem>: " + json, e);
}
}
}

View File

@@ -1,85 +0,0 @@
package com.yolo.keyborad.utils;
import javax.net.ssl.*;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.URL;
import java.security.cert.CertificateException;
import java.security.cert.X509Certificate;
import java.util.Locale;
public class ApplePayUtil {
private static class TrustAnyTrustManager implements X509TrustManager {
@Override
public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
@Override
public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return new X509Certificate[]{};
}
}
private static class TrustAnyHostnameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
private static final String url_sandbox = "https://sandbox.itunes.apple.com/verifyReceipt";
private static final String url_verify = "https://buy.itunes.apple.com/verifyReceipt";
/**
* 苹果服务器验证
*
* @param receipt 账单
* @return null 或返回结果 沙盒 https://sandbox.itunes.apple.com/verifyReceipt
* @url 要验证的地址
*/
public static String buyAppVerify(String receipt, int type) throws Exception {
//环境判断 线上/开发环境用不同的请求链接
String url = "";
if (type == 0) {
url = url_sandbox; //沙盒测试
} else {
url = url_verify; //线上测试
}
SSLContext sc = SSLContext.getInstance("SSL");
sc.init(null, new TrustManager[]{new TrustAnyTrustManager()}, new java.security.SecureRandom());
URL console = new URL(url);
HttpsURLConnection conn = (HttpsURLConnection) console.openConnection();
conn.setSSLSocketFactory(sc.getSocketFactory());
conn.setHostnameVerifier(new TrustAnyHostnameVerifier());
conn.setRequestMethod("POST");
conn.setRequestProperty("content-type", "text/json");
conn.setRequestProperty("Proxy-Connection", "Keep-Alive");
conn.setDoInput(true);
conn.setDoOutput(true);
BufferedOutputStream hurlBufOus = new BufferedOutputStream(conn.getOutputStream());
//拼成固定的格式传给平台
String str = String.format(Locale.CHINA, "{\"receipt-data\":\"" + receipt + "\"}");
hurlBufOus.write(str.getBytes());
hurlBufOus.flush();
InputStream is = conn.getInputStream();
BufferedReader reader = new BufferedReader(new InputStreamReader(is));
String line = null;
StringBuffer sb = new StringBuffer();
while ((line = reader.readLine()) != null) {
sb.append(line);
}
return sb.toString();
}
}

View File

@@ -0,0 +1,70 @@
package com.yolo.keyborad.utils;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.security.PublicKey;
import java.security.cert.CertificateFactory;
import java.security.cert.X509Certificate;
import java.util.Base64;
public class JwtParser {
private static final ObjectMapper objectMapper = new ObjectMapper();
/**
* 解析 JWT 并返回 JsonNode
*/
public static JsonNode parsePayload(String signedPayload) throws Exception {
// 从 JWT header 中提取公钥
PublicKey publicKey = extractPublicKeyFromJWT(signedPayload);
// 解码 JWT使用公钥验证
Jws<Claims> claimsJws = Jwts.parserBuilder()
.setSigningKey(publicKey)
.build()
.parseClaimsJws(signedPayload);
Claims claims = claimsJws.getBody();
// 将 Claims 转换为 JsonNode
return objectMapper.valueToTree(claims);
}
/**
* 从 JWT 的 x5c header 中提取公钥
*/
private static PublicKey extractPublicKeyFromJWT(String jwt) throws Exception {
// 解析 JWT header不验证签名
String[] parts = jwt.split("\\.");
if (parts.length < 2) {
throw new IllegalArgumentException("Invalid JWT format");
}
// 解码 header
String headerJson = new String(Base64.getUrlDecoder().decode(parts[0])); // 使用 URL 安全的 Base64 解码
JSONObject header = new JSONObject(headerJson);
// 获取 x5c 证书链(第一个证书包含公钥)
JSONArray x5cArray = header.getJSONArray("x5c");
if (x5cArray.length() == 0) {
throw new IllegalArgumentException("No x5c certificates found in JWT header");
}
// 获取第一个证书Base64 编码标准格式非URL安全格式
String certBase64 = x5cArray.getString(0);
// x5c 中的证书使用标准 Base64 编码(非 URL 安全编码)
byte[] certBytes = Base64.getDecoder().decode(certBase64);
// 生成 X509 证书并提取公钥
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
X509Certificate cert = (X509Certificate) certFactory.generateCertificate(
new ByteArrayInputStream(certBytes)
);
return cert.getPublicKey();
}
}

View File

@@ -87,7 +87,7 @@ sa-token:
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
active-timeout: -1
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
is-concurrent: false
is-concurrent: true
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token
is-share: false
# token 风格默认可取值uuid、simple-uuid、random-32、random-64、random-128、tik

View File

@@ -3,7 +3,7 @@
<mapper namespace="com.yolo.keyborad.mapper.I18nMessageMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.I18nMessage">
<!--@mbg.generated-->
<!--@Table i18n_message-->
<!--@Table keyboard_i18n_message-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="code" jdbcType="VARCHAR" property="code" />
<result column="locale" jdbcType="VARCHAR" property="locale" />
@@ -15,6 +15,6 @@
</sql>
<select id="selectByCodeAndLocale" resultMap="BaseResultMap">
SELECT * FROM "i18n_message" WHERE code = #{code,jdbcType=VARCHAR} AND locale = #{locale,jdbcType=VARCHAR}
SELECT * FROM "keyboard_i18n_message" WHERE code = #{code,jdbcType=VARCHAR} AND locale = #{locale,jdbcType=VARCHAR}
</select>
</mapper>

View File

@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardProductItemsMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardProductItems">
<!--@mbg.generated-->
<!--@Table keyboard_product_items-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="product_id" jdbcType="VARCHAR" property="productId" />
<result column="type" jdbcType="VARCHAR" property="type" />
<result column="name" jdbcType="VARCHAR" property="name" />
<result column="unit" jdbcType="VARCHAR" property="unit" />
<result column="duration_value" jdbcType="INTEGER" property="durationValue" />
<result column="duration_unit" jdbcType="VARCHAR" property="durationUnit" />
<result column="price" jdbcType="NUMERIC" property="price" />
<result column="currency" jdbcType="VARCHAR" property="currency" />
<result column="description" jdbcType="VARCHAR" property="description" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
<result column="duration_days" jdbcType="INTEGER" property="durationDays" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, product_id, "type", "name", unit, duration_value, duration_unit, price, currency,
description, created_at, updated_at, duration_days
</sql>
</mapper>

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardThemePurchaseMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardThemePurchase">
<!--@mbg.generated-->
<!--@Table keyboard_theme_purchase-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="order_no" jdbcType="VARCHAR" property="orderNo" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="theme_id" jdbcType="BIGINT" property="themeId" />
<result column="cost_points" jdbcType="NUMERIC" property="costPoints" />
<result column="paid_points" jdbcType="NUMERIC" property="paidPoints" />
<result column="pay_status" jdbcType="SMALLINT" property="payStatus" />
<result column="wallet_tx_id" jdbcType="BIGINT" property="walletTxId" />
<result column="refund_points" jdbcType="INTEGER" property="refundPoints" />
<result column="refunded_at" jdbcType="TIMESTAMP" property="refundedAt" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="paid_at" jdbcType="TIMESTAMP" property="paidAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
<result column="remark" jdbcType="VARCHAR" property="remark" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, order_no, user_id, theme_id, cost_points, paid_points, pay_status, wallet_tx_id,
refund_points, refunded_at, created_at, paid_at, updated_at, remark
</sql>
</mapper>

View File

@@ -7,7 +7,7 @@
<id column="id" jdbcType="BIGINT" property="id" />
<result column="theme_name" jdbcType="VARCHAR" property="themeName" />
<result column="theme_price" jdbcType="NUMERIC" property="themePrice" />
<result column="theme_tag" jdbcType="ARRAY" property="themeTag" typeHandler="com.yolo.keyborad.typehandler.StringArrayTypeHandler" />
<result column="theme_tag" jdbcType="VARCHAR" property="themeTag"/>
<result column="theme_download" jdbcType="VARCHAR" property="themeDownload" />
<result column="theme_style" jdbcType="BIGINT" property="themeStyle" />
<result column="theme_status" jdbcType="BOOLEAN" property="themeStatus" />

View File

@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserLoginLogMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserLoginLog">
<!--@mbg.generated-->
<!--@Table keyboard_user_login_log-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="login_time" jdbcType="TIMESTAMP" property="loginTime" />
<result column="ip_address" jdbcType="VARCHAR" property="ipAddress" />
<result column="device_info" jdbcType="VARCHAR" property="deviceInfo" />
<result column="os" jdbcType="VARCHAR" property="os" />
<result column="platform" jdbcType="VARCHAR" property="platform" />
<result column="status" jdbcType="VARCHAR" property="status" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, login_time, ip_address, device_info, os, platform, "status", created_at,
updated_at
</sql>
</mapper>

View File

@@ -17,11 +17,13 @@
<result column="password" jdbcType="VARCHAR" property="password" />
<result column="subject_id" jdbcType="VARCHAR" property="subjectId" />
<result column="email_verified" jdbcType="BOOLEAN" property="emailVerified" />
<result column="is_vip" jdbcType="BOOLEAN" property="isVip" />
<result column="vip_expiry" jdbcType="TIMESTAMP" property="vipExpiry" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, "uid", nick_name, gender, avatar_url, created_at, updated_at, deleted, email,
"status", "password", subject_id, email_verified
"status", "password", subject_id, email_verified, is_vip, vip_expiry
</sql>
<update id="updateByuid">

View File

@@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserPurchaseRecordsMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords">
<!--@mbg.generated-->
<!--@Table keyboard_user_purchase_records-->
<id column="id" jdbcType="INTEGER" property="id" />
<result column="user_id" jdbcType="INTEGER" property="userId" />
<result column="product_id" jdbcType="VARCHAR" property="productId" />
<result column="purchase_quantity" jdbcType="INTEGER" property="purchaseQuantity" />
<result column="price" jdbcType="NUMERIC" property="price" />
<result column="currency" jdbcType="VARCHAR" property="currency" />
<result column="purchase_time" jdbcType="TIMESTAMP" property="purchaseTime" />
<result column="purchase_type" jdbcType="VARCHAR" property="purchaseType" />
<result column="status" jdbcType="VARCHAR" property="status" />
<result column="payment_method" jdbcType="VARCHAR" property="paymentMethod" />
<result column="transaction_id" jdbcType="VARCHAR" property="transactionId" />
<result column="original_transaction_id" jdbcType="VARCHAR" property="originalTransactionId" />
<result column="product_ids" jdbcType="VARCHAR" property="productIds" />
<result column="purchase_date" jdbcType="TIMESTAMP" property="purchaseDate" />
<result column="expires_date" jdbcType="TIMESTAMP" property="expiresDate" />
<result column="environment" jdbcType="VARCHAR" property="environment" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, product_id, purchase_quantity, price, currency, purchase_time, purchase_type,
"status", payment_method, transaction_id, original_transaction_id, product_ids, purchase_date,
expires_date, environment
</sql>
</mapper>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserThemesMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserThemes">
<!--@mbg.generated-->
<!--@Table keyboard_user_themes-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="theme_id" jdbcType="BIGINT" property="themeId" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="view_deleted" jdbcType="BOOLEAN" property="viewDeleted" />
<result column="updated_at" jdbcType="BOOLEAN" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, theme_id, user_id, created_at, view_deleted, updated_at
</sql>
</mapper>

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardUserWalletMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserWallet">
<!--@mbg.generated-->
<!--@Table keyboard_user_wallet-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="balance" jdbcType="NUMERIC" property="balance" />
<result column="version" jdbcType="INTEGER" property="version" />
<result column="status" jdbcType="SMALLINT" property="status" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, balance, version, "status", created_at, updated_at
</sql>
</mapper>

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.KeyboardWalletTransactionMapper">
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardWalletTransaction">
<!--@mbg.generated-->
<!--@Table keyboard_wallet_transaction-->
<id column="id" jdbcType="BIGINT" property="id" />
<result column="user_id" jdbcType="BIGINT" property="userId" />
<result column="order_id" jdbcType="BIGINT" property="orderId" />
<result column="amount" jdbcType="NUMERIC" property="amount" />
<result column="type" jdbcType="SMALLINT" property="type" />
<result column="before_balance" jdbcType="NUMERIC" property="beforeBalance" />
<result column="after_balance" jdbcType="NUMERIC" property="afterBalance" />
<result column="description" jdbcType="VARCHAR" property="description" />
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
</resultMap>
<sql id="Base_Column_List">
<!--@mbg.generated-->
id, user_id, order_id, amount, "type", before_balance, after_balance, description,
created_at
</sql>
</mapper>

View File

@@ -0,0 +1,88 @@
package com.yolo.keyborad;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import java.io.FileInputStream;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.PrivateKey;
import java.security.interfaces.RSAPrivateKey;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.NoSuchAlgorithmException;
import java.io.FileNotFoundException;
import java.security.PublicKey;
import java.io.IOException;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
public class AppStoreJWT {
// Apple JWT 的配置参数
private static final String ISSUER_ID = "178b442e-b7be-4526-bd13-ab293d019df0";
private static final String KEY_ID = "Y7TF7BV74G";
private static final String PRIVATE_KEY_PATH = "/Users/ziin/Desktop/keyborad-backend/src/main/resources/SubscriptionKey_Y7TF7BV74G.p8";
// 生成 JWT
public static String generateJWT() throws Exception {
// 读取私钥
PrivateKey privateKey = loadPrivateKey(PRIVATE_KEY_PATH);
// 当前时间
long now = System.currentTimeMillis();
Date issuedAt = new Date(now);
Date expiration = new Date(now + 30 * 60 * 1000); // 过期时间,通常是 20 分钟
Map<String, Object> headerMap = new HashMap<>();
headerMap.put("type", "JWT");
headerMap.put("kid", KEY_ID);
// 使用私钥签名生成 JWT
return Jwts.builder()
.setIssuer(ISSUER_ID)
.setAudience("appstoreconnect-v1")
.setIssuedAt(issuedAt)
.setExpiration(expiration)
.claim("bid", "com.loveKey.nyx") // 使用 claim() 方法添加自定义字段
.setHeader(headerMap)
.signWith(privateKey, SignatureAlgorithm.ES256) // ES256 签名算法
.compact();
}
// 加载私钥
static PrivateKey loadPrivateKey(String privateKeyPath) throws Exception {
try {
// 读取 p8 文件内容
String privateKeyContent = new String(Files.readAllBytes(Paths.get(privateKeyPath)));
// 移除 PEM 格式的头部和尾部标记,以及换行符
privateKeyContent = privateKeyContent
.replace("-----BEGIN PRIVATE KEY-----", "")
.replace("-----END PRIVATE KEY-----", "")
.replaceAll("\\s", ""); // 移除所有空白字符(包括换行符)
// Base64 解码
byte[] encoded = Base64.getDecoder().decode(privateKeyContent);
// 生成私钥
KeyFactory keyFactory = KeyFactory.getInstance("EC");
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(encoded);
return keyFactory.generatePrivate(keySpec);
} catch (IOException | NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new Exception("加载私钥失败", e);
}
}
// 调用生成的 JWT
public static void main(String[] args) {
try {
String jwt = generateJWT();
System.out.println("生成的 JWT: " + jwt);
} catch (Exception e) {
e.printStackTrace();
}
}
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,35 @@
package com.yolo.keyborad;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONException;
import cn.hutool.json.JSONObject;
public class SendAttemptsParser {
public static void main(String[] args) throws JSONException {
String jsonResponse = "{\n" +
" \"signedPayload\": \"eyJhbGciOiJFUzI1NiIsIng1YyI6WyJNSUlFTVRDQ0E3YWdBd0lCQWdJUVI4S0h6ZG41NTRaL1VvcmFkTng5dHpBS0JnZ3Foa2pPUFFRREF6QjFNVVF3UWdZRFZRUURERHRCY0hCc1pTQlhiM0pzWkhkcFpHVWdSR1YyWld4dmNHVnlJRkpsYkdGMGFXOXVjeUJEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURUxNQWtHQTFVRUN3d0NSell4RXpBUkJnTlZCQW9NQ2tGd2NHeGxJRWx1WXk0eEN6QUpCZ05WQkFZVEFsVlRNQjRYRFRJMU1Ea3hPVEU1TkRRMU1Wb1hEVEkzTVRBeE16RTNORGN5TTFvd2daSXhRREErQmdOVkJBTU1OMUJ5YjJRZ1JVTkRJRTFoWXlCQmNIQWdVM1J2Y21VZ1lXNWtJR2xVZFc1bGN5QlRkRzl5WlNCU1pXTmxhWEIwSUZOcFoyNXBibWN4TERBcUJnTlZCQXNNSTBGd2NHeGxJRmR2Y214a2QybGtaU0JFWlhabGJHOXdaWElnVW1Wc1lYUnBiMjV6TVJNd0VRWURWUVFLREFwQmNIQnNaU0JKYm1NdU1Rc3dDUVlEVlFRR0V3SlZVekJaTUJNR0J5cUdTTTQ5QWdFR0NDcUdTTTQ5QXdFSEEwSUFCTm5WdmhjdjdpVCs3RXg1dEJNQmdyUXNwSHpJc1hSaTBZeGZlazdsdjh3RW1qL2JIaVd0TndKcWMyQm9IenNRaUVqUDdLRklJS2c0WTh5MC9ueW51QW1qZ2dJSU1JSUNCREFNQmdOVkhSTUJBZjhFQWpBQU1COEdBMVVkSXdRWU1CYUFGRDh2bENOUjAxREptaWc5N2JCODVjK2xrR0taTUhBR0NDc0dBUVVGQndFQkJHUXdZakF0QmdnckJnRUZCUWN3QW9ZaGFIUjBjRG92TDJObGNuUnpMbUZ3Y0d4bExtTnZiUzkzZDJSeVp6WXVaR1Z5TURFR0NDc0dBUVVGQnpBQmhpVm9kSFJ3T2k4dmIyTnpjQzVoY0hCc1pTNWpiMjB2YjJOemNEQXpMWGQzWkhKbk5qQXlNSUlCSGdZRFZSMGdCSUlCRlRDQ0FSRXdnZ0VOQmdvcWhraUc5Mk5rQlFZQk1JSCtNSUhEQmdnckJnRUZCUWNDQWpDQnRneUJzMUpsYkdsaGJtTmxJRzl1SUhSb2FYTWdZMlZ5ZEdsbWFXTmhkR1VnWW5rZ1lXNTVJSEJoY25SNUlHRnpjM1Z0WlhNZ1lXTmpaWEIwWVc1alpTQnZaaUIwYUdVZ2RHaGxiaUJoY0hCc2FXTmhZbXhsSUhOMFlXNWtZWEprSUhSbGNtMXpJR0Z1WkNCamIyNWthWFJwYjI1eklHOW1JSFZ6WlN3Z1kyVnlkR2xtYVdOaGRHVWdjRzlzYVdONUlHRnVaQ0JqWlhKMGFXWnBZMkYwYVc5dUlIQnlZV04wYVdObElITjBZWFJsYldWdWRITXVNRFlHQ0NzR0FRVUZCd0lCRmlwb2RIUndPaTh2ZDNkM0xtRndjR3hsTG1OdmJTOWpaWEowYVdacFkyRjBaV0YxZEdodmNtbDBlUzh3SFFZRFZSME9CQllFRklGaW9HNHdNTVZBMWt1OXpKbUdOUEFWbjNlcU1BNEdBMVVkRHdFQi93UUVBd0lIZ0RBUUJnb3Foa2lHOTJOa0Jnc0JCQUlGQURBS0JnZ3Foa2pPUFFRREF3TnBBREJtQWpFQStxWG5SRUM3aFhJV1ZMc0x4em5qUnBJelBmN1ZIejlWL0NUbTgrTEpsclFlcG5tY1B2R0xOY1g2WFBubGNnTEFBakVBNUlqTlpLZ2c1cFE3OWtuRjRJYlRYZEt2OHZ1dElETVhEbWpQVlQzZEd2RnRzR1J3WE95d1Iya1pDZFNyZmVvdCIsIk1JSURGakNDQXB5Z0F3SUJBZ0lVSXNHaFJ3cDBjMm52VTRZU3ljYWZQVGp6Yk5jd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NakV3TXpFM01qQXpOekV3V2hjTk16WXdNekU1TURBd01EQXdXakIxTVVRd1FnWURWUVFERER0QmNIQnNaU0JYYjNKc1pIZHBaR1VnUkdWMlpXeHZjR1Z5SUZKbGJHRjBhVzl1Y3lCRFpYSjBhV1pwWTJGMGFXOXVJRUYxZEdodmNtbDBlVEVMTUFrR0ExVUVDd3dDUnpZeEV6QVJCZ05WQkFvTUNrRndjR3hsSUVsdVl5NHhDekFKQmdOVkJBWVRBbFZUTUhZd0VBWUhLb1pJemowQ0FRWUZLNEVFQUNJRFlnQUVic1FLQzk0UHJsV21aWG5YZ3R4emRWSkw4VDBTR1luZ0RSR3BuZ24zTjZQVDhKTUViN0ZEaTRiQm1QaENuWjMvc3E2UEYvY0djS1hXc0w1dk90ZVJoeUo0NXgzQVNQN2NPQithYW85MGZjcHhTdi9FWkZibmlBYk5nWkdoSWhwSW80SDZNSUgzTUJJR0ExVWRFd0VCL3dRSU1BWUJBZjhDQVFBd0h3WURWUjBqQkJnd0ZvQVV1N0Rlb1ZnemlKcWtpcG5ldnIzcnI5ckxKS3N3UmdZSUt3WUJCUVVIQVFFRU9qQTRNRFlHQ0NzR0FRVUZCekFCaGlwb2RIUndPaTh2YjJOemNDNWhjSEJzWlM1amIyMHZiMk56Y0RBekxXRndjR3hsY205dmRHTmhaek13TndZRFZSMGZCREF3TGpBc29DcWdLSVltYUhSMGNEb3ZMMk55YkM1aGNIQnNaUzVqYjIwdllYQndiR1Z5YjI5MFkyRm5NeTVqY213d0hRWURWUjBPQkJZRUZEOHZsQ05SMDFESm1pZzk3YkI4NWMrbGtHS1pNQTRHQTFVZER3RUIvd1FFQXdJQkJqQVFCZ29xaGtpRzkyTmtCZ0lCQkFJRkFEQUtCZ2dxaGtqT1BRUURBd05vQURCbEFqQkFYaFNxNUl5S29nTUNQdHc0OTBCYUI2NzdDYUVHSlh1ZlFCL0VxWkdkNkNTamlDdE9udU1UYlhWWG14eGN4ZmtDTVFEVFNQeGFyWlh2TnJreFUzVGtVTUkzM3l6dkZWVlJUNHd4V0pDOTk0T3NkY1o0K1JHTnNZRHlSNWdtZHIwbkRHZz0iLCJNSUlDUXpDQ0FjbWdBd0lCQWdJSUxjWDhpTkxGUzVVd0NnWUlLb1pJemowRUF3TXdaekViTUJrR0ExVUVBd3dTUVhCd2JHVWdVbTl2ZENCRFFTQXRJRWN6TVNZd0pBWURWUVFMREIxQmNIQnNaU0JEWlhKMGFXWnBZMkYwYVc5dUlFRjFkR2h2Y21sMGVURVRNQkVHQTFVRUNnd0tRWEJ3YkdVZ1NXNWpMakVMTUFrR0ExVUVCaE1DVlZNd0hoY05NVFF3TkRNd01UZ3hPVEEyV2hjTk16a3dORE13TVRneE9UQTJXakJuTVJzd0dRWURWUVFEREJKQmNIQnNaU0JTYjI5MElFTkJJQzBnUnpNeEpqQWtCZ05WQkFzTUhVRndjR3hsSUVObGNuUnBabWxqWVhScGIyNGdRWFYwYUc5eWFYUjVNUk13RVFZRFZRUUtEQXBCY0hCc1pTQkpibU11TVFzd0NRWURWUVFHRXdKVlV6QjJNQkFHQnlxR1NNNDlBZ0VHQlN1QkJBQWlBMklBQkpqcEx6MUFjcVR0a3lKeWdSTWMzUkNWOGNXalRuSGNGQmJaRHVXbUJTcDNaSHRmVGpqVHV4eEV0WC8xSDdZeVlsM0o2WVJiVHpCUEVWb0EvVmhZREtYMUR5eE5CMGNUZGRxWGw1ZHZNVnp0SzUxN0lEdll1VlRaWHBta09sRUtNYU5DTUVBd0hRWURWUjBPQkJZRUZMdXczcUZZTTRpYXBJcVozcjY5NjYvYXl5U3JNQThHQTFVZEV3RUIvd1FGTUFNQkFmOHdEZ1lEVlIwUEFRSC9CQVFEQWdFR01Bb0dDQ3FHU000OUJBTURBMmdBTUdVQ01RQ0Q2Y0hFRmw0YVhUUVkyZTN2OUd3T0FFWkx1Tit5UmhIRkQvM21lb3locG12T3dnUFVuUFdUeG5TNGF0K3FJeFVDTUcxbWloREsxQTNVVDgyTlF6NjBpbU9sTTI3amJkb1h0MlFmeUZNbStZaGlkRGtMRjF2TFVhZ002QmdENTZLeUtBPT0iXX0.eyJub3RpZmljYXRpb25UeXBlIjoiVEVTVCIsIm5vdGlmaWNhdGlvblVVSUQiOiIxNjg5YjA5OS00MTMzLTQzYjctOGFiYi05YzdmMDNlZDkwN2YiLCJkYXRhIjp7ImJ1bmRsZUlkIjoiY29tLmxvdmVLZXkubnl4IiwiZW52aXJvbm1lbnQiOiJTYW5kYm94In0sInZlcnNpb24iOiIyLjAiLCJzaWduZWREYXRlIjoxNzY1NTM5Nzk3MjA2fQ.UhmtfQ5Bk2aqvGOKPdOBLQDpssZ7aT4SgnlR29talFerwtfXjBh3b1vqsc565a8U4g8NJNwOt_dkCqtMjUO-Vw\",\n" +
" \"firstSendAttemptResult\": \"SUCCESS\",\n" +
" \"sendAttempts\": [\n" +
" {\n" +
" \"attemptDate\": 1765539797242,\n" +
" \"sendAttemptResult\": \"SUCCESS\"\n" +
" }\n" +
" ]\n" +
"}"; // 将响应字符串传递进来
JSONObject jsonObject = new JSONObject(jsonResponse);
// 获取 sendAttempts 数组
JSONArray sendAttempts = jsonObject.getJSONArray("sendAttempts");
// 解析每个 sendAttempt
for (int i = 0; i < sendAttempts.size(); i++) {
JSONObject attempt = sendAttempts.getJSONObject(i);
long attemptDate = attempt.getLong("attemptDate"); // 发送尝试时间戳
String sendAttemptResult = attempt.getStr("sendAttemptResult"); // 发送尝试结果
System.out.println("Attempt Date: " + attemptDate);
System.out.println("Send Attempt Result: " + sendAttemptResult);
}
}
}

View File

@@ -0,0 +1,32 @@
package com.yolo.keyborad;
import com.apple.itunes.storekit.signature.JWSSignatureCreator;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.UUID;
/*
* @author: ziin
* @date: 2025/12/12 18:56
*/
public class test {
public static void main(String[] args) throws Exception {
try {
String jwt = AppStoreJWT.generateJWT();
URL url = new URL("https://api.storekit.itunes.apple.com/inApps/v1/subscriptions"); // 你需要访问的 Apple API URL
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("GET");
connection.setRequestProperty("Authorization", "Bearer " + jwt); // 设置 JWT 为 Bearer Token
// 处理响应
int responseCode = connection.getResponseCode();
System.out.println("Response Code: " + responseCode);
} catch (Exception e) {
e.printStackTrace();
}
}
}