Compare commits
30 Commits
fb94c2069d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 95fb77a575 | |||
| abfac871fd | |||
| 198650556f | |||
| 4666180b73 | |||
| 35c45abf73 | |||
| 65cd9d9fae | |||
| 0156156440 | |||
| 27a8911b7f | |||
| 323baa876f | |||
| 2621321dea | |||
| 86738e3d1b | |||
| a237bc2987 | |||
| 8e26488738 | |||
| f95762138b | |||
| 495485cc07 | |||
| cd6eca9cbb | |||
| c54c14de58 | |||
| c305dfaae4 | |||
| 0ad9de1011 | |||
| d9a778f5aa | |||
| a70c1f4049 | |||
| c1dd4faf0e | |||
| a24a795887 | |||
| 2e16183cb8 | |||
| b4c35b0df3 | |||
| f391f9dfe1 | |||
| 07ff9a5ff2 | |||
| 071e130a45 | |||
| 360ac7a885 | |||
| 5121bf3455 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -33,3 +33,5 @@ build/
|
|||||||
### VS Code ###
|
### VS Code ###
|
||||||
.vscode/
|
.vscode/
|
||||||
/CLAUDE.md
|
/CLAUDE.md
|
||||||
|
/AGENTS.md
|
||||||
|
/src/test/
|
||||||
|
|||||||
33
AGENTS.md
33
AGENTS.md
@@ -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.
|
|
||||||
9
pom.xml
9
pom.xml
@@ -55,6 +55,13 @@
|
|||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.nacos</groupId>
|
||||||
|
<artifactId>nacos-client</artifactId>
|
||||||
|
<version>3.1.1</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- qdrant向量数据库 sdk -->
|
<!-- qdrant向量数据库 sdk -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.qdrant</groupId>
|
<groupId>io.qdrant</groupId>
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>com.apple.itunes.storekit</groupId>
|
<groupId>com.apple.itunes.storekit</groupId>
|
||||||
<artifactId>app-store-server-library</artifactId>
|
<artifactId>app-store-server-library</artifactId>
|
||||||
<version>3.6.0</version>
|
<version>4.0.0</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- x-file-storage -->
|
<!-- x-file-storage -->
|
||||||
|
|||||||
@@ -50,7 +50,15 @@ public enum ErrorCode {
|
|||||||
INSUFFICIENT_BALANCE(50013, "余额不足"),
|
INSUFFICIENT_BALANCE(50013, "余额不足"),
|
||||||
THEME_NOT_FOUND(40410, "主题不存在"),
|
THEME_NOT_FOUND(40410, "主题不存在"),
|
||||||
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
|
THEME_ALREADY_PURCHASED(50014, "主题已购买"),
|
||||||
THEME_NOT_AVAILABLE(50015, "主题不可购买");
|
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, "商品不存在"),
|
||||||
|
NO_QUOTA_AND_NOT_VIP(50022, "免费次数已用完,请开通VIP");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 状态码
|
* 状态码
|
||||||
*/
|
*/
|
||||||
|
|||||||
51
src/main/java/com/yolo/keyborad/config/AppConfig.java
Normal file
51
src/main/java/com/yolo/keyborad/config/AppConfig.java
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
package com.yolo.keyborad.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/16 21:18
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class AppConfig {
|
||||||
|
|
||||||
|
private UserRegisterProperties userRegisterProperties = new UserRegisterProperties();
|
||||||
|
|
||||||
|
private QdrantConfig qdrantConfig = new QdrantConfig();
|
||||||
|
|
||||||
|
private LLmConfig llmConfig = new LLmConfig();
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class UserRegisterProperties {
|
||||||
|
|
||||||
|
//新用户注册时的免费使用次数
|
||||||
|
private Integer freeTrialQuota = 3;
|
||||||
|
|
||||||
|
//新用户注册时的奖励余额
|
||||||
|
private BigDecimal rewardBalance = BigDecimal.valueOf(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class QdrantConfig {
|
||||||
|
//向量搜索时的返回数量限制
|
||||||
|
private Integer vectorSearchLimit = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public static class LLmConfig {
|
||||||
|
//LLM系统提示语
|
||||||
|
private String systemPrompt = """
|
||||||
|
Format rules:
|
||||||
|
- Return EXACTLY 3 replies.
|
||||||
|
- Use "<SPLIT>" as the separator.
|
||||||
|
- reply1<SPLIT>reply2<SPLIT>reply3
|
||||||
|
""";
|
||||||
|
|
||||||
|
//聊天消息最大长度
|
||||||
|
private Integer maxMessageLength = 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ import org.springframework.ai.retry.RetryUtils;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.util.MultiValueMap;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -32,6 +35,7 @@ public class LLMConfig {
|
|||||||
public OpenAiApi openAiApi() {
|
public OpenAiApi openAiApi() {
|
||||||
return OpenAiApi.builder()
|
return OpenAiApi.builder()
|
||||||
.apiKey(apiKey)
|
.apiKey(apiKey)
|
||||||
|
.headers(MultiValueMap.fromSingleValue(Map.of("X-Title", "key of love")))
|
||||||
.baseUrl(baseUrl)
|
.baseUrl(baseUrl)
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package com.yolo.keyborad.config;
|
||||||
|
|
||||||
|
import com.alibaba.nacos.api.NacosFactory;
|
||||||
|
import com.alibaba.nacos.api.config.ConfigService;
|
||||||
|
import com.alibaba.nacos.api.config.listener.Listener;
|
||||||
|
import com.alibaba.nacos.api.exception.NacosException;
|
||||||
|
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import com.fasterxml.jackson.dataformat.yaml.YAMLFactory;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.util.Properties;
|
||||||
|
import java.util.concurrent.Executor;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
@Configuration
|
||||||
|
public class NacosAppConfigCenter {
|
||||||
|
|
||||||
|
private final ObjectMapper yamlMapper = new ObjectMapper(new YAMLFactory())
|
||||||
|
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ConfigService nacosConfigService(
|
||||||
|
@Value("${nacos.config.server-addr}") String serverAddr
|
||||||
|
) throws NacosException {
|
||||||
|
Properties p = new Properties();
|
||||||
|
p.put("serverAddr", serverAddr);
|
||||||
|
return NacosFactory.createConfigService(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public DynamicAppConfig dynamicAppConfig(
|
||||||
|
ConfigService configService,
|
||||||
|
@Value("${nacos.config.group}") String group,
|
||||||
|
@Value("${nacos.config.data-id}") String dataId
|
||||||
|
) throws Exception {
|
||||||
|
|
||||||
|
DynamicAppConfig holder = new DynamicAppConfig();
|
||||||
|
|
||||||
|
// 启动先拉一次
|
||||||
|
String content = configService.getConfig(dataId, group, 3000);
|
||||||
|
if (content != null && !content.isBlank()) {
|
||||||
|
holder.ref.set(parse(content));
|
||||||
|
log.info("Loaded nacos config: dataId={}, group={}", dataId, group);
|
||||||
|
} else {
|
||||||
|
log.warn("Empty nacos config: dataId={}, group={}", dataId, group);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 监听热更新
|
||||||
|
configService.addListener(dataId, group, new Listener() {
|
||||||
|
@Override public Executor getExecutor() { return null; }
|
||||||
|
@Override public void receiveConfigInfo(String configInfo) {
|
||||||
|
try {
|
||||||
|
AppConfig newCfg = parse(configInfo);
|
||||||
|
holder.ref.set(newCfg);
|
||||||
|
log.info("Refreshed nacos config: dataId={}, group={}", dataId, group);
|
||||||
|
log.info("New config: {}", newCfg.toString());
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 解析失败不覆盖旧配置
|
||||||
|
log.error("Failed to refresh nacos config: dataId={}, keep old config.", dataId, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return holder;
|
||||||
|
}
|
||||||
|
|
||||||
|
private AppConfig parse(String yaml) throws Exception {
|
||||||
|
if (yaml == null || yaml.isBlank()) return new AppConfig();
|
||||||
|
return yamlMapper.readValue(yaml, AppConfig.class);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public static class DynamicAppConfig {
|
||||||
|
private final AtomicReference<AppConfig> ref = new AtomicReference<>(new AppConfig());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,4 +37,27 @@ public class RedisConfig {
|
|||||||
template.afterPropertiesSet();
|
template.afterPropertiesSet();
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置对象序列化的RedisTemplate
|
||||||
|
* @param connectionFactory Redis连接工厂
|
||||||
|
* @return RedisTemplate实例
|
||||||
|
*/
|
||||||
|
@Bean("objectRedisTemplate")
|
||||||
|
public org.springframework.data.redis.core.RedisTemplate<String, Object> objectRedisTemplate(RedisConnectionFactory connectionFactory) {
|
||||||
|
org.springframework.data.redis.core.RedisTemplate<String, Object> template = new org.springframework.data.redis.core.RedisTemplate<>();
|
||||||
|
template.setConnectionFactory(connectionFactory);
|
||||||
|
|
||||||
|
// 设置key序列化方式
|
||||||
|
template.setKeySerializer(new StringRedisSerializer());
|
||||||
|
// 设置value序列化方式(使用JSON序列化)
|
||||||
|
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||||
|
// 设置hash key序列化方式
|
||||||
|
template.setHashKeySerializer(new StringRedisSerializer());
|
||||||
|
// 设置hash value序列化方式
|
||||||
|
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
|
||||||
|
|
||||||
|
template.afterPropertiesSet();
|
||||||
|
return template;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,6 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/character/listByTagWithNotLogin",
|
"/character/listByTagWithNotLogin",
|
||||||
"/character/detailWithNotLogin",
|
"/character/detailWithNotLogin",
|
||||||
"/character/addUserCharacter",
|
"/character/addUserCharacter",
|
||||||
"/api/apple/validate-receipt",
|
|
||||||
"/character/list",
|
"/character/list",
|
||||||
"/user/resetPassWord",
|
"/user/resetPassWord",
|
||||||
"/chat/talk",
|
"/chat/talk",
|
||||||
@@ -92,7 +91,18 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
|||||||
"/themes/purchased",
|
"/themes/purchased",
|
||||||
"/themes/purchase/list",
|
"/themes/purchase/list",
|
||||||
"/themes/detail",
|
"/themes/detail",
|
||||||
"/themes/recommended"
|
"/themes/recommended",
|
||||||
|
"/themes/search",
|
||||||
|
"/user-themes/batch-delete",
|
||||||
|
"/products/listByType",
|
||||||
|
"/products/detail",
|
||||||
|
"/products/inApp/list",
|
||||||
|
"/products/subscription/list",
|
||||||
|
"/purchase/handle",
|
||||||
|
"/apple/notification",
|
||||||
|
"/apple/receipt",
|
||||||
|
"/apple/validate-receipt"
|
||||||
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@Bean
|
@Bean
|
||||||
|
|||||||
@@ -1,27 +1,88 @@
|
|||||||
package com.yolo.keyborad.controller;
|
package com.yolo.keyborad.controller;
|
||||||
|
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
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.AppleReceiptValidationResult;
|
||||||
|
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||||
import com.yolo.keyborad.service.AppleReceiptService;
|
import com.yolo.keyborad.service.AppleReceiptService;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/apple")
|
@RequestMapping("/apple")
|
||||||
|
@Slf4j
|
||||||
public class AppleReceiptController {
|
public class AppleReceiptController {
|
||||||
|
|
||||||
private final AppleReceiptService appleReceiptService;
|
private final AppleReceiptService appleReceiptService;
|
||||||
|
private final ApplePurchaseService applePurchaseService;
|
||||||
|
|
||||||
public AppleReceiptController(AppleReceiptService appleReceiptService) {
|
public AppleReceiptController(AppleReceiptService appleReceiptService,
|
||||||
|
ApplePurchaseService applePurchaseService) {
|
||||||
this.appleReceiptService = appleReceiptService;
|
this.appleReceiptService = appleReceiptService;
|
||||||
|
this.applePurchaseService = applePurchaseService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@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")
|
@PostMapping("/validate-receipt")
|
||||||
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
|
public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) {
|
||||||
String receipt = body.get("receipt");
|
if (body == null) {
|
||||||
return appleReceiptService.validateReceipt(receipt);
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
|
||||||
|
}
|
||||||
|
String signedPayload = body.get("signedPayload");
|
||||||
|
if (signedPayload == null || signedPayload.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
||||||
|
}
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(signedPayload);
|
||||||
|
applePurchaseService.processPurchase(userId, validationResult);
|
||||||
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 接收 Apple 服务器通知
|
||||||
|
* 处理来自 Apple 的服务器到服务器通知,包括订阅续订、退款等事件
|
||||||
|
* 所有验证和处理逻辑都委托给 service 层
|
||||||
|
*
|
||||||
|
* @param body 请求体,包含 signedPayload 字段
|
||||||
|
* @return 处理结果
|
||||||
|
* @throws BusinessException 当 signedPayload 为空时抛出
|
||||||
|
*/
|
||||||
|
@PostMapping("/notification")
|
||||||
|
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body) {
|
||||||
|
// 参数校验
|
||||||
|
if (body == null) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
String signedPayload = body.get("signedPayload");
|
||||||
|
if (signedPayload == null || signedPayload.isBlank()) {
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 委托给 service 层处理所有通知逻辑
|
||||||
|
appleReceiptService.processNotification(signedPayload);
|
||||||
|
|
||||||
|
return ResultUtils.success(Boolean.TRUE);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,25 +11,18 @@ import com.yolo.keyborad.mapper.QdrantPayloadMapper;
|
|||||||
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
|
import com.yolo.keyborad.model.dto.chat.ChatSaveReq;
|
||||||
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
import com.yolo.keyborad.service.ChatService;
|
||||||
import com.yolo.keyborad.service.KeyboardCharacterService;
|
|
||||||
import com.yolo.keyborad.service.impl.QdrantVectorService;
|
import com.yolo.keyborad.service.impl.QdrantVectorService;
|
||||||
import io.qdrant.client.grpc.JsonWithInt;
|
import io.qdrant.client.grpc.JsonWithInt;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.ai.chat.client.ChatClient;
|
|
||||||
import org.springframework.ai.openai.OpenAiChatOptions;
|
|
||||||
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
import org.springframework.ai.openai.OpenAiEmbeddingModel;
|
||||||
import org.springframework.http.codec.ServerSentEvent;
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
import reactor.core.publisher.Flux;
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
|
||||||
import reactor.core.scheduler.Schedulers;
|
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -42,12 +35,6 @@ import java.util.Map;
|
|||||||
@Tag(name = "聊天", description = "聊天接口")
|
@Tag(name = "聊天", description = "聊天接口")
|
||||||
public class ChatController {
|
public class ChatController {
|
||||||
|
|
||||||
// 最大消息长度限制
|
|
||||||
private static final int MAX_MESSAGE_LENGTH = 1000;
|
|
||||||
|
|
||||||
@Resource
|
|
||||||
private ChatClient client;
|
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private OpenAiEmbeddingModel embeddingModel;
|
private OpenAiEmbeddingModel embeddingModel;
|
||||||
|
|
||||||
@@ -55,109 +42,13 @@ public class ChatController {
|
|||||||
private QdrantVectorService qdrantVectorService;
|
private QdrantVectorService qdrantVectorService;
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardCharacterService keyboardCharacterService;
|
private ChatService chatService;
|
||||||
|
|
||||||
|
|
||||||
@PostMapping("/talk")
|
@PostMapping("/talk")
|
||||||
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
|
@Operation(summary = "聊天润色接口", description = "聊天润色接口")
|
||||||
public Flux<ServerSentEvent<ChatStreamMessage>> talk(@RequestBody ChatReq chatReq){
|
public Flux<ServerSentEvent<ChatStreamMessage>> talk(@RequestBody ChatReq chatReq){
|
||||||
// 1. 参数校验
|
return chatService.talk(chatReq);
|
||||||
if (chatReq == null) {
|
|
||||||
log.error("聊天请求参数为空");
|
|
||||||
throw new BusinessException(ErrorCode.PARAMS_ERROR);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatReq.getCharacterId() == null) {
|
|
||||||
log.error("键盘人设ID为空");
|
|
||||||
throw new BusinessException(ErrorCode.CHAT_CHARACTER_ID_EMPTY);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (StrUtil.isBlank(chatReq.getMessage())) {
|
|
||||||
log.error("聊天消息为空");
|
|
||||||
throw new BusinessException(ErrorCode.CHAT_MESSAGE_EMPTY);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chatReq.getMessage().length() > MAX_MESSAGE_LENGTH) {
|
|
||||||
log.error("聊天消息过长,长度: {}", chatReq.getMessage().length());
|
|
||||||
throw new BusinessException(ErrorCode.CHAT_MESSAGE_TOO_LONG);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 验证键盘人设是否存在
|
|
||||||
KeyboardCharacter character = keyboardCharacterService.getById(chatReq.getCharacterId());
|
|
||||||
if (character == null) {
|
|
||||||
log.error("键盘人设不存在,ID: {}", chatReq.getCharacterId());
|
|
||||||
throw new BusinessException(ErrorCode.CHAT_CHARACTER_NOT_FOUND);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. LLM 流式输出
|
|
||||||
Flux<ChatStreamMessage> llmFlux = client
|
|
||||||
.prompt(character.getPrompt())
|
|
||||||
.system("""
|
|
||||||
Format rules:
|
|
||||||
- Return EXACTLY 3 replies.
|
|
||||||
- Use "<SPLIT>" as the separator.
|
|
||||||
- reply1<SPLIT>reply2<SPLIT>reply3
|
|
||||||
""")
|
|
||||||
.user(chatReq.getMessage())
|
|
||||||
.options(OpenAiChatOptions.builder()
|
|
||||||
.user(StpUtil.getLoginIdAsString())
|
|
||||||
.build())
|
|
||||||
.stream()
|
|
||||||
.content()
|
|
||||||
.concatMap(chunk -> {
|
|
||||||
// 拆成单字符
|
|
||||||
List<String> chars = chunk.codePoints()
|
|
||||||
.mapToObj(cp -> new String(Character.toChars(cp)))
|
|
||||||
.toList();
|
|
||||||
|
|
||||||
// 按 3 个字符批量发送
|
|
||||||
List<String> batched = new ArrayList<>();
|
|
||||||
StringBuilder sb = new StringBuilder();
|
|
||||||
for (String ch : chars) {
|
|
||||||
sb.append(ch);
|
|
||||||
if (sb.length() >= 3) {
|
|
||||||
batched.add(sb.toString());
|
|
||||||
sb.setLength(0);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!sb.isEmpty()) {
|
|
||||||
batched.add(sb.toString());
|
|
||||||
}
|
|
||||||
|
|
||||||
return Flux.fromIterable(batched)
|
|
||||||
.map(s -> new ChatStreamMessage("llm_chunk", s));
|
|
||||||
})
|
|
||||||
.doOnError(error -> log.error("LLM调用失败", error))
|
|
||||||
.onErrorResume(error ->
|
|
||||||
Flux.just(new ChatStreamMessage("error", "LLM服务暂时不可用,请稍后重试"))
|
|
||||||
);
|
|
||||||
|
|
||||||
// 4. 向量搜索Flux(一次性发送搜索结果)
|
|
||||||
Flux<ChatStreamMessage> searchFlux = Mono
|
|
||||||
.fromCallable(() -> qdrantVectorService.searchText(chatReq.getMessage()))
|
|
||||||
.subscribeOn(Schedulers.boundedElastic()) // 避免阻塞 event-loop
|
|
||||||
.map(list -> new ChatStreamMessage("search_result", list))
|
|
||||||
.doOnError(error -> log.error("向量搜索失败", error))
|
|
||||||
.onErrorResume(error ->
|
|
||||||
Mono.just(new ChatStreamMessage("search_result", new ArrayList<>()))
|
|
||||||
)
|
|
||||||
.flux();
|
|
||||||
|
|
||||||
// 5. 结束标记
|
|
||||||
Flux<ChatStreamMessage> doneFlux =
|
|
||||||
Flux.just(new ChatStreamMessage("done", null));
|
|
||||||
|
|
||||||
// 6. 合并所有Flux
|
|
||||||
Flux<ChatStreamMessage> merged =
|
|
||||||
Flux.merge(llmFlux, searchFlux)
|
|
||||||
.concatWith(doneFlux);
|
|
||||||
|
|
||||||
// 7. SSE 包装
|
|
||||||
return merged.map(msg ->
|
|
||||||
ServerSentEvent.builder(msg)
|
|
||||||
.event(msg.getType())
|
|
||||||
.build()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -91,4 +91,12 @@ public class ThemesController {
|
|||||||
return ResultUtils.success(result);
|
return ResultUtils.success(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/search")
|
||||||
|
@Operation(summary = "搜索主题", description = "根据主题名称模糊搜索主题")
|
||||||
|
public BaseResponse<List<KeyboardThemesRespVO>> searchThemes(@RequestParam String themeName) {
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
List<KeyboardThemesRespVO> result = themesService.searchThemesByName(themeName, userId);
|
||||||
|
return ResultUtils.success(result);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import com.yolo.keyborad.common.BaseResponse;
|
|||||||
import com.yolo.keyborad.common.ResultUtils;
|
import com.yolo.keyborad.common.ResultUtils;
|
||||||
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
||||||
import com.yolo.keyborad.model.dto.user.*;
|
import com.yolo.keyborad.model.dto.user.*;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserInfoRespVO;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
@@ -15,12 +16,15 @@ import io.swagger.v3.oas.annotations.Operation;
|
|||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.BeanUtils;
|
import org.springframework.beans.BeanUtils;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.text.SimpleDateFormat;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 用户前端控制器
|
* 用户前端控制器
|
||||||
*
|
*
|
||||||
@@ -39,6 +43,9 @@ public class UserController {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private com.yolo.keyborad.service.KeyboardFeedbackService feedbackService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 苹果登录
|
* 苹果登录
|
||||||
*
|
*
|
||||||
@@ -47,21 +54,22 @@ public class UserController {
|
|||||||
@PostMapping("/appleLogin")
|
@PostMapping("/appleLogin")
|
||||||
@Operation(summary = "苹果登录", description = "苹果登录接口")
|
@Operation(summary = "苹果登录", description = "苹果登录接口")
|
||||||
@Parameter(name = "code", required = true, description = "苹果登录凭证", example = "123456")
|
@Parameter(name = "code", required = true, description = "苹果登录凭证", example = "123456")
|
||||||
public BaseResponse<KeyboardUserRespVO> appleLogin(@RequestBody AppleLoginReq appleLoginReq) throws Exception {
|
public BaseResponse<KeyboardUserRespVO> appleLogin(@RequestBody AppleLoginReq appleLoginReq, HttpServletRequest request) throws Exception {
|
||||||
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken()));
|
return ResultUtils.success(appleService.login(appleLoginReq.getIdentityToken(), request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/logout")
|
@GetMapping("/logout")
|
||||||
@Operation(summary = "退出登录", description = "退出登录接口")
|
@Operation(summary = "退出登录", description = "退出登录接口")
|
||||||
public BaseResponse<Boolean> logout() {
|
public BaseResponse<Boolean> logout() {
|
||||||
|
StpUtil.logout(StpUtil.getLoginIdAsLong());
|
||||||
StpUtil.logoutByTokenValue(StpUtil.getTokenValue());
|
StpUtil.logoutByTokenValue(StpUtil.getTokenValue());
|
||||||
return ResultUtils.success(true);
|
return ResultUtils.success(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@Operation(summary = "登录", description = "登录接口")
|
@Operation(summary = "登录", description = "登录接口")
|
||||||
public BaseResponse<KeyboardUserRespVO> login(@RequestBody UserLoginDTO userLoginDTO) {
|
public BaseResponse<KeyboardUserRespVO> login(@RequestBody UserLoginDTO userLoginDTO, HttpServletRequest request) {
|
||||||
return ResultUtils.success(userService.login(userLoginDTO));
|
return ResultUtils.success(userService.login(userLoginDTO, request));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/updateInfo")
|
@PostMapping("/updateInfo")
|
||||||
@@ -75,14 +83,23 @@ public class UserController {
|
|||||||
public BaseResponse<KeyboardUserInfoRespVO> detail() {
|
public BaseResponse<KeyboardUserInfoRespVO> detail() {
|
||||||
long loginId = StpUtil.getLoginIdAsLong();
|
long loginId = StpUtil.getLoginIdAsLong();
|
||||||
KeyboardUser keyboardUser = userService.getById(loginId);
|
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")
|
@PostMapping("/register")
|
||||||
@Operation(summary = "用户注册",description = "用户注册接口")
|
@Operation(summary = "用户注册",description = "用户注册接口")
|
||||||
public BaseResponse<Boolean> register(@RequestBody UserRegisterDTO userRegisterDTO) {
|
public BaseResponse<Boolean> register(@RequestBody UserRegisterDTO userRegisterDTO) {
|
||||||
userService.userRegister(userRegisterDTO);
|
return ResultUtils.success(userService.userRegister(userRegisterDTO));
|
||||||
return ResultUtils.success(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping("/sendVerifyMail")
|
@PostMapping("/sendVerifyMail")
|
||||||
@@ -103,4 +120,15 @@ public class UserController {
|
|||||||
public BaseResponse<Boolean> resetPassWord(@RequestBody ResetPassWordDTO resetPassWordDTO) {
|
public BaseResponse<Boolean> resetPassWord(@RequestBody ResetPassWordDTO resetPassWordDTO) {
|
||||||
return ResultUtils.success(userService.resetPassWord(resetPassWordDTO));
|
return ResultUtils.success(userService.resetPassWord(resetPassWordDTO));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/feedback")
|
||||||
|
@Operation(summary = "提交反馈", description = "用户提交反馈接口")
|
||||||
|
public BaseResponse<Boolean> submitFeedback(@RequestBody FeedbackSubmitReq req) {
|
||||||
|
KeyboardFeedback feedback = new KeyboardFeedback();
|
||||||
|
feedback.setContent(req.getContent());
|
||||||
|
feedback.setCreatedAt(new java.util.Date());
|
||||||
|
return ResultUtils.success(feedbackService.save(feedback));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package com.yolo.keyborad.listener;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
||||||
|
import com.yolo.keyborad.service.KeyboardCharacterService;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.boot.ApplicationArguments;
|
||||||
|
import org.springframework.boot.ApplicationRunner;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 人设缓存初始化器
|
||||||
|
* 在应用启动时将所有人设缓存到Redis
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@Slf4j
|
||||||
|
public class CharacterCacheInitializer implements ApplicationRunner {
|
||||||
|
|
||||||
|
private static final String CHARACTER_CACHE_KEY = "character:";
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardCharacterService characterService;
|
||||||
|
|
||||||
|
@Resource(name = "objectRedisTemplate")
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run(ApplicationArguments args) {
|
||||||
|
try {
|
||||||
|
log.info("开始缓存人设列表到Redis...");
|
||||||
|
List<KeyboardCharacter> characters = characterService.list();
|
||||||
|
for (KeyboardCharacter character : characters) {
|
||||||
|
String key = CHARACTER_CACHE_KEY + character.getId();
|
||||||
|
redisTemplate.opsForValue().set(key, character, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
|
log.info("人设列表缓存完成,共缓存 {} 条记录", characters.size());
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("缓存人设列表失败", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 17:06
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardFeedbackMapper extends BaseMapper<KeyboardFeedback> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 13:29
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardUserCallLogMapper extends BaseMapper<KeyboardUserCallLog> {
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
package com.yolo.keyborad.mapper;
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
/*
|
|
||||||
* @author: ziin
|
|
||||||
* @date: 2025/12/2 18:10
|
|
||||||
*/
|
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/11 20:35
|
||||||
|
*/
|
||||||
|
|
||||||
public interface KeyboardUserMapper extends BaseMapper<KeyboardUser> {
|
public interface KeyboardUserMapper extends BaseMapper<KeyboardUser> {
|
||||||
Integer updateByuid(KeyboardUser keyboardUser);
|
Integer updateByuid(KeyboardUser keyboardUser);
|
||||||
}
|
}
|
||||||
@@ -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> {
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
package com.yolo.keyborad.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/16 16:00
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardUserQuotaTotalMapper extends BaseMapper<KeyboardUserQuotaTotal> {
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.yolo.keyborad.model.dto.user;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户反馈提交请求
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Schema(description = "用户反馈提交请求")
|
||||||
|
public class FeedbackSubmitReq {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反馈内容
|
||||||
|
*/
|
||||||
|
@Schema(description = "反馈内容", required = true)
|
||||||
|
private String content;
|
||||||
|
}
|
||||||
@@ -13,7 +13,7 @@ import lombok.Data;
|
|||||||
|
|
||||||
@Schema(description="多语言消息表")
|
@Schema(description="多语言消息表")
|
||||||
@Data
|
@Data
|
||||||
@TableName("i18n_message")
|
@TableName("keyboard_i18n_message")
|
||||||
public class I18nMessage {
|
public class I18nMessage {
|
||||||
@TableId("id")
|
@TableId("id")
|
||||||
@Schema(description="主键")
|
@Schema(description="主键")
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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/17 17:06
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户反馈表
|
||||||
|
*/
|
||||||
|
@Schema(description="用户反馈表")
|
||||||
|
@Data
|
||||||
|
@TableName(value = "keyboard_feedback")
|
||||||
|
public class KeyboardFeedback {
|
||||||
|
/**
|
||||||
|
* 主键ID
|
||||||
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description="主键ID")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户反馈内容
|
||||||
|
*/
|
||||||
|
@TableField(value = "content")
|
||||||
|
@Schema(description="用户反馈内容")
|
||||||
|
private String content;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 反馈创建时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description="反馈创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -1,83 +1,124 @@
|
|||||||
package com.yolo.keyborad.model.entity;
|
package com.yolo.keyborad.model.entity;
|
||||||
|
|
||||||
import com.baomidou.mybatisplus.annotation.IdType;
|
import com.baomidou.mybatisplus.annotation.IdType;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import com.baomidou.mybatisplus.annotation.TableId;
|
import com.baomidou.mybatisplus.annotation.TableId;
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableName;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
|
||||||
|
|
||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/2 18:08
|
* @date: 2025/12/11 20:35
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
@Schema
|
||||||
@Data
|
@Data
|
||||||
@Schema(description="用户信息")
|
@TableName(value = "keyboard_user")
|
||||||
public class KeyboardUser {
|
public class KeyboardUser {
|
||||||
|
/**
|
||||||
@Schema(description="主键ID")
|
* 主键
|
||||||
@TableId(type = IdType.AUTO)
|
*/
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description = "主键")
|
||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
@Schema(description="用户ID")
|
/**
|
||||||
|
* 用户 Id
|
||||||
|
*/
|
||||||
|
@TableField(value = "\"uid\"")
|
||||||
|
@Schema(description = "用户 Id")
|
||||||
private Long uid;
|
private Long uid;
|
||||||
|
|
||||||
@Schema(description="用户昵称")
|
/**
|
||||||
|
* 用户昵称
|
||||||
|
*/
|
||||||
|
@TableField(value = "nick_name")
|
||||||
|
@Schema(description = "用户昵称")
|
||||||
private String nickName;
|
private String nickName;
|
||||||
|
|
||||||
@Schema(description="性别")
|
/**
|
||||||
|
* 性别
|
||||||
|
*/
|
||||||
|
@TableField(value = "gender")
|
||||||
|
@Schema(description = "性别")
|
||||||
private Integer gender;
|
private Integer gender;
|
||||||
|
|
||||||
@Schema(description="头像URL")
|
/**
|
||||||
|
* 头像地址
|
||||||
|
*/
|
||||||
|
@TableField(value = "avatar_url")
|
||||||
|
@Schema(description = "头像地址")
|
||||||
private String avatarUrl;
|
private String avatarUrl;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 创建时间
|
* 创建时间
|
||||||
*/
|
*/
|
||||||
@Schema(description="创建时间")
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description = "创建时间")
|
||||||
private Date createdAt;
|
private Date createdAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 更新时间
|
* 更新时间
|
||||||
*/
|
*/
|
||||||
@Schema(description="更新时间")
|
@TableField(value = "updated_at")
|
||||||
|
@Schema(description = "更新时间")
|
||||||
private Date updatedAt;
|
private Date updatedAt;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否删除(默认否)
|
* 是否删除(默认否)
|
||||||
*/
|
*/
|
||||||
@Schema(description="是否删除(默认否)")
|
@TableField(value = "deleted")
|
||||||
|
@Schema(description = "是否删除(默认否)")
|
||||||
private Boolean deleted;
|
private Boolean deleted;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 邮箱地址
|
* 邮箱地址
|
||||||
*/
|
*/
|
||||||
@Schema(description="邮箱地址")
|
@TableField(value = "email")
|
||||||
|
@Schema(description = "邮箱地址")
|
||||||
private String email;
|
private String email;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否禁用
|
* 是否禁用
|
||||||
*/
|
*/
|
||||||
@Schema(description="是否禁用")
|
@TableField(value = "\"status\"")
|
||||||
|
@Schema(description = "是否禁用")
|
||||||
private Boolean status;
|
private Boolean status;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 密码
|
* 密码
|
||||||
*/
|
*/
|
||||||
@Schema(description="密码")
|
@TableField(value = "\"password\"")
|
||||||
|
@Schema(description = "密码")
|
||||||
private String password;
|
private String password;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 苹果登录subjectId
|
* 苹果登录subjectId
|
||||||
*/
|
*/
|
||||||
@Schema(description="苹果登录subjectId")
|
@TableField(value = "subject_id")
|
||||||
|
@Schema(description = "苹果登录subjectId")
|
||||||
private String subjectId;
|
private String subjectId;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 邮箱是否验证
|
* 邮箱是否验证
|
||||||
*/
|
*/
|
||||||
@Schema(description="邮箱是否验证")
|
@TableField(value = "email_verified")
|
||||||
|
@Schema(description = "邮箱是否验证")
|
||||||
private Boolean emailVerified;
|
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;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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/17 13:29
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户每次调用日志(用于记录token、模型、耗时、成功率等)
|
||||||
|
*/
|
||||||
|
@Schema(description="用户每次调用日志(用于记录token、模型、耗时、成功率等)")
|
||||||
|
@Data
|
||||||
|
@TableName(value = "keyboard_user_call_log")
|
||||||
|
public class KeyboardUserCallLog {
|
||||||
|
@TableId(value = "id", type = IdType.AUTO)
|
||||||
|
@Schema(description="")
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户ID
|
||||||
|
*/
|
||||||
|
@TableField(value = "user_id")
|
||||||
|
@Schema(description="用户ID")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 幂等请求ID,避免重试导致重复记录
|
||||||
|
*/
|
||||||
|
@TableField(value = "request_id")
|
||||||
|
@Schema(description="幂等请求ID,避免重试导致重复记录")
|
||||||
|
private String requestId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用功能来源
|
||||||
|
*/
|
||||||
|
@TableField(value = "feature")
|
||||||
|
@Schema(description="调用功能来源")
|
||||||
|
private String feature;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用的模型名称
|
||||||
|
*/
|
||||||
|
@TableField(value = "model")
|
||||||
|
@Schema(description="调用的模型名称")
|
||||||
|
private String model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输入token数
|
||||||
|
*/
|
||||||
|
@TableField(value = "input_tokens")
|
||||||
|
@Schema(description="输入token数")
|
||||||
|
private Integer inputTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 输出token数
|
||||||
|
*/
|
||||||
|
@TableField(value = "output_tokens")
|
||||||
|
@Schema(description="输出token数")
|
||||||
|
private Integer outputTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 总token数(input+output)
|
||||||
|
*/
|
||||||
|
@TableField(value = "total_tokens")
|
||||||
|
@Schema(description="总token数(input+output)")
|
||||||
|
private Integer totalTokens;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用是否成功
|
||||||
|
*/
|
||||||
|
@TableField(value = "success")
|
||||||
|
@Schema(description="调用是否成功")
|
||||||
|
private Boolean success;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用耗时(毫秒)
|
||||||
|
*/
|
||||||
|
@TableField(value = "latency_ms")
|
||||||
|
@Schema(description="调用耗时(毫秒)")
|
||||||
|
private Integer latencyMs;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败错误码(可空)
|
||||||
|
*/
|
||||||
|
@TableField(value = "error_code")
|
||||||
|
@Schema(description="失败错误码(可空)")
|
||||||
|
private String errorCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 调用记录创建时间
|
||||||
|
*/
|
||||||
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description="调用记录创建时间")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
@TableField(value = "gen_id")
|
||||||
|
@Schema(description="生成 Id")
|
||||||
|
private String genId;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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/16 16:00
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户免费功能永久总次数额度表(所有功能共用)
|
||||||
|
*/
|
||||||
|
@Schema(description="用户免费功能永久总次数额度表(所有功能共用)")
|
||||||
|
@Data
|
||||||
|
@TableName(value = "keyboard_user_quota_total")
|
||||||
|
public class KeyboardUserQuotaTotal {
|
||||||
|
/**
|
||||||
|
* 用户唯一ID,对应系统用户
|
||||||
|
*/
|
||||||
|
@TableId(value = "user_id", type = IdType.AUTO)
|
||||||
|
@Schema(description="用户唯一ID,对应系统用户")
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 免费体验的永久总次数上限(可通过运营活动增加)
|
||||||
|
*/
|
||||||
|
@TableField(value = "total_quota")
|
||||||
|
@Schema(description="免费体验的永久总次数上限(可通过运营活动增加)")
|
||||||
|
private Integer totalQuota;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 已消耗的免费次数
|
||||||
|
*/
|
||||||
|
@TableField(value = "used_quota")
|
||||||
|
@Schema(description="已消耗的免费次数")
|
||||||
|
private Integer usedQuota;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 乐观锁版本号(并发控制预留字段)
|
||||||
|
*/
|
||||||
|
@TableField(value = "version")
|
||||||
|
@Schema(description="乐观锁版本号(并发控制预留字段)")
|
||||||
|
private Integer version;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 首次创建额度记录的时间(通常为注册时间)
|
||||||
|
*/
|
||||||
|
@TableField(value = "created_at")
|
||||||
|
@Schema(description="首次创建额度记录的时间(通常为注册时间)")
|
||||||
|
private Date createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 最近一次额度发生变化的时间(消耗或赠送)
|
||||||
|
*/
|
||||||
|
@TableField(value = "updated_at")
|
||||||
|
@Schema(description="最近一次额度发生变化的时间(消耗或赠送)")
|
||||||
|
private Date updatedAt;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
@@ -3,6 +3,8 @@ package com.yolo.keyborad.model.vo.user;
|
|||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.Date;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/2 18:08
|
* @date: 2025/12/2 18:08
|
||||||
@@ -38,4 +40,16 @@ public class KeyboardUserInfoRespVO {
|
|||||||
@Schema(description="邮箱是否验证")
|
@Schema(description="邮箱是否验证")
|
||||||
private Boolean emailVerified;
|
private Boolean emailVerified;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否是 VIP
|
||||||
|
*/
|
||||||
|
@Schema(description = "是否是 VIP")
|
||||||
|
private Boolean isVip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIP 过期时间
|
||||||
|
*/
|
||||||
|
@Schema(description = "VIP 过期时间")
|
||||||
|
private String vipExpiry;
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
package com.yolo.keyborad.model.vo.user;
|
package com.yolo.keyborad.model.vo.user;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.TableField;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
|
||||||
@@ -42,4 +43,16 @@ public class KeyboardUserRespVO {
|
|||||||
|
|
||||||
@Schema(description = "token")
|
@Schema(description = "token")
|
||||||
private String token;
|
private String token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否是 VIP
|
||||||
|
*/
|
||||||
|
@Schema(description = "是否是 VIP")
|
||||||
|
private Boolean isVip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VIP 过期时间
|
||||||
|
*/
|
||||||
|
@Schema(description = "VIP 过期时间")
|
||||||
|
private Date vipExpiry;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||||
|
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理苹果购买后的业务逻辑
|
||||||
|
*/
|
||||||
|
public interface ApplePurchaseService {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 基于验签结果处理购买逻辑(订阅 / 内购)
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @param validationResult 苹果验签结果
|
||||||
|
*/
|
||||||
|
void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理订阅相关通知(新订阅、续订、续订失败、过期等)
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理退款相关通知(退款、退款拒绝、退款撤销)
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
void handleRefundNotification(ResponseBodyV2DecodedPayload notification);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理续订偏好变更通知
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消费请求通知
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
|
||||||
|
}
|
||||||
@@ -5,9 +5,17 @@ import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
|||||||
public interface AppleReceiptService {
|
public interface AppleReceiptService {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 base64 app receipt 是否有效,并返回解析结果。
|
* 验证 JWS 交易数据是否有效,并返回解析结果。
|
||||||
*
|
*
|
||||||
* @param appReceipt Base64 的 app receipt(以 MI... 开头那串)
|
* @param signedTransaction JWS 格式的签名交易数据
|
||||||
*/
|
*/
|
||||||
AppleReceiptValidationResult validateReceipt(String appReceipt);
|
AppleReceiptValidationResult validateReceipt(String signedTransaction);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 Apple 服务器通知
|
||||||
|
* 验证通知签名并根据通知类型分发到相应的处理逻辑
|
||||||
|
*
|
||||||
|
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||||
|
*/
|
||||||
|
void processNotification(String signedPayload);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,14 @@
|
|||||||
package com.yolo.keyborad.service;
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/8 15:16
|
* @date: 2025/12/8 15:16
|
||||||
*/
|
*/
|
||||||
public interface ChatService {
|
public interface ChatService {
|
||||||
|
Flux<ServerSentEvent<ChatStreamMessage>> talk(ChatReq chatReq);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yolo.keyborad.service;
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apple相关API
|
* Apple相关API
|
||||||
@@ -14,6 +15,7 @@ public interface IAppleService {
|
|||||||
* 登录
|
* 登录
|
||||||
*
|
*
|
||||||
* @param identityToken JWT身份令牌
|
* @param identityToken JWT身份令牌
|
||||||
|
* @param request HTTP请求
|
||||||
*/
|
*/
|
||||||
KeyboardUserRespVO login(String identityToken) throws Exception;
|
KeyboardUserRespVO login(String identityToken, HttpServletRequest request) throws Exception;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 17:06
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardFeedbackService extends IService<KeyboardFeedback>{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -36,4 +36,12 @@ public interface KeyboardThemesService extends IService<KeyboardThemes>{
|
|||||||
*/
|
*/
|
||||||
List<KeyboardThemesRespVO> getRecommendedThemes(Long userId);
|
List<KeyboardThemesRespVO> getRecommendedThemes(Long userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据主题名称模糊搜索主题
|
||||||
|
* @param themeName 主题名称关键字
|
||||||
|
* @param userId 用户ID
|
||||||
|
* @return 主题列表
|
||||||
|
*/
|
||||||
|
List<KeyboardThemesRespVO> searchThemesByName(String themeName, Long userId);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 13:29
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardUserCallLogService extends IService<KeyboardUserCallLog>{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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>{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package com.yolo.keyborad.service;
|
||||||
|
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
|
||||||
|
import com.baomidou.mybatisplus.extension.service.IService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/16 16:00
|
||||||
|
*/
|
||||||
|
|
||||||
|
public interface KeyboardUserQuotaTotalService extends IService<KeyboardUserQuotaTotal>{
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.extension.service.IService;
|
|||||||
import com.yolo.keyborad.model.dto.user.*;
|
import com.yolo.keyborad.model.dto.user.*;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
@@ -15,7 +16,7 @@ public interface UserService extends IService<KeyboardUser> {
|
|||||||
|
|
||||||
KeyboardUser createUserWithSubjectId(String sub);
|
KeyboardUser createUserWithSubjectId(String sub);
|
||||||
|
|
||||||
KeyboardUserRespVO login(UserLoginDTO userLoginDTO);
|
KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request);
|
||||||
|
|
||||||
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
|
Boolean updateUserInfo(KeyboardUserReq keyboardUser);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,925 @@
|
|||||||
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
|
import com.apple.itunes.storekit.model.JWSRenewalInfoDecodedPayload;
|
||||||
|
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||||
|
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
||||||
|
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||||
|
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
||||||
|
import com.apple.itunes.storekit.verification.VerificationException;
|
||||||
|
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;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private SignedDataVerifier signedDataVerifier;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理苹果购买(订阅或内购)
|
||||||
|
* 主要流程:
|
||||||
|
* 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()) {
|
||||||
|
log.error("Invalid receipt, reason={}", validationResult.getReason());
|
||||||
|
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 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification) {
|
||||||
|
if (notification == null || notification.getData() == null) {
|
||||||
|
log.warn("Subscription notification data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationTypeV2 type = notification.getNotificationType();
|
||||||
|
log.info("Processing subscription notification: type={}, subtype={}",
|
||||||
|
type, notification.getSubtype());
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 解码交易信息
|
||||||
|
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
|
||||||
|
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
|
||||||
|
log.warn("No signed transaction info in notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JWSTransactionDecodedPayload transaction =
|
||||||
|
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||||
|
|
||||||
|
String originalTransactionId = transaction.getOriginalTransactionId();
|
||||||
|
String productId = transaction.getProductId();
|
||||||
|
|
||||||
|
// 根据原始交易ID查询用户购买记录
|
||||||
|
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, originalTransactionId)
|
||||||
|
.orderByDesc(KeyboardUserPurchaseRecords::getId)
|
||||||
|
.last("LIMIT 1")
|
||||||
|
.list();
|
||||||
|
|
||||||
|
if (records == null || records.isEmpty()) {
|
||||||
|
log.warn("No purchase record found for originalTransactionId={}", originalTransactionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
KeyboardUserPurchaseRecords existingRecord = records.get(0);
|
||||||
|
Long userId = existingRecord.getUserId().longValue();
|
||||||
|
|
||||||
|
// 查询商品信息
|
||||||
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||||
|
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
|
||||||
|
log.warn("Product not found or not subscription type: productId={}", productId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据通知类型处理
|
||||||
|
switch (type) {
|
||||||
|
case SUBSCRIBED:
|
||||||
|
case DID_RENEW:
|
||||||
|
case OFFER_REDEEMED:
|
||||||
|
// 续订成功:创建新的购买记录并延长VIP
|
||||||
|
handleSuccessfulRenewal(userId, product, transaction);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case DID_FAIL_TO_RENEW:
|
||||||
|
// 续订失败:记录日志,不修改VIP状态
|
||||||
|
log.warn("Subscription renewal failed for user={}, productId={}", userId, productId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EXPIRED:
|
||||||
|
handleSubscriptionExpired(userId);
|
||||||
|
break;
|
||||||
|
case GRACE_PERIOD_EXPIRED:
|
||||||
|
// 订阅过期:更新用户VIP状态
|
||||||
|
handleSubscriptionExpired(userId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case RENEWAL_EXTENDED:
|
||||||
|
case RENEWAL_EXTENSION:
|
||||||
|
// 续订延长:延长VIP有效期
|
||||||
|
Instant expiresDate = transaction.getExpiresDate() != null
|
||||||
|
? Instant.ofEpochMilli(transaction.getExpiresDate())
|
||||||
|
: null;
|
||||||
|
extendVip(userId, product, expiresDate);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.info("Subscription notification type {} - no specific action", type);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
log.error("Failed to verify transaction in notification", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing subscription notification", e);
|
||||||
|
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理订阅通知失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理退款相关通知
|
||||||
|
* 包括:退款、退款拒绝、退款撤销
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public void handleRefundNotification(ResponseBodyV2DecodedPayload notification) {
|
||||||
|
if (notification == null || notification.getData() == null) {
|
||||||
|
log.warn("Refund notification data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationTypeV2 type = notification.getNotificationType();
|
||||||
|
log.info("Processing refund notification: type={}, subtype={}",
|
||||||
|
type, notification.getSubtype());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String signedTransactionInfo = notification.getData().getSignedTransactionInfo();
|
||||||
|
if (signedTransactionInfo == null || signedTransactionInfo.isBlank()) {
|
||||||
|
log.warn("No signed transaction info in refund notification");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
JWSTransactionDecodedPayload transaction =
|
||||||
|
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
||||||
|
|
||||||
|
String transactionId = transaction.getTransactionId();
|
||||||
|
|
||||||
|
// 查询购买记录
|
||||||
|
KeyboardUserPurchaseRecords record = purchaseRecordsService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
|
||||||
|
.one();
|
||||||
|
|
||||||
|
if (record == null) {
|
||||||
|
log.warn("No purchase record found for transactionId={}", transactionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Long userId = record.getUserId().longValue();
|
||||||
|
KeyboardProductItems product = productItemsService.getProductEntityByProductId(record.getProductId());
|
||||||
|
|
||||||
|
if (product == null) {
|
||||||
|
log.warn("Product not found: productId={}", record.getProductId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case REFUND:
|
||||||
|
// 退款:撤销用户权益
|
||||||
|
handleRefund(userId, product, record);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case REFUND_DECLINED:
|
||||||
|
// 退款拒绝:无需操作
|
||||||
|
log.info("Refund declined for user={}, transactionId={}", userId, transactionId);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case REFUND_REVERSED:
|
||||||
|
// 退款撤销:恢复用户权益
|
||||||
|
handleRefundReversed(userId, product, record);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.info("Refund notification type {} - no specific action", type);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
log.error("Failed to verify transaction in refund notification", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing refund notification", e);
|
||||||
|
throw new BusinessException(ErrorCode.OPERATION_ERROR, "处理退款通知失败");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理续订偏好变更通知
|
||||||
|
* 包括:续订偏好变更、续订状态变更、价格上涨
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification) {
|
||||||
|
if (notification == null || notification.getData() == null) {
|
||||||
|
log.warn("Renewal preference notification data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NotificationTypeV2 type = notification.getNotificationType();
|
||||||
|
log.info("Processing renewal preference notification: type={}, subtype={}",
|
||||||
|
type, notification.getSubtype());
|
||||||
|
|
||||||
|
try {
|
||||||
|
String signedRenewalInfo = notification.getData().getSignedRenewalInfo();
|
||||||
|
if (signedRenewalInfo != null && !signedRenewalInfo.isBlank()) {
|
||||||
|
JWSRenewalInfoDecodedPayload renewalInfo =
|
||||||
|
signedDataVerifier.verifyAndDecodeRenewalInfo(signedRenewalInfo);
|
||||||
|
|
||||||
|
log.info("Renewal preference changed: autoRenewStatus={}, productId={}",
|
||||||
|
renewalInfo.getAutoRenewStatus(),
|
||||||
|
renewalInfo.getAutoRenewProductId());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录偏好变更,但不修改用户状态
|
||||||
|
// 实际的续订状态会在下次续订时通过 DID_RENEW 或 DID_FAIL_TO_RENEW 通知
|
||||||
|
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
log.error("Failed to verify renewal info in notification", e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing renewal preference notification", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理消费请求通知
|
||||||
|
* Apple 请求提供消费数据以评估退款请求
|
||||||
|
*
|
||||||
|
* @param notification 解码后的通知载荷
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification) {
|
||||||
|
if (notification == null || notification.getData() == null) {
|
||||||
|
log.warn("Consumption request notification data is null");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info("Received consumption request notification - manual review may be required");
|
||||||
|
|
||||||
|
// TODO: 实现消费数据上报逻辑
|
||||||
|
// 可以调用 AppStoreServerAPIClient.sendConsumptionData() 方法
|
||||||
|
// 提供用户消费状态、交付状态等信息,帮助 Apple 评估退款请求
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理成功的续订
|
||||||
|
* 创建新的购买记录并延长VIP有效期
|
||||||
|
*/
|
||||||
|
private void handleSuccessfulRenewal(Long userId, KeyboardProductItems product,
|
||||||
|
JWSTransactionDecodedPayload transaction) {
|
||||||
|
// 幂等性检查
|
||||||
|
boolean exists = purchaseRecordsService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserPurchaseRecords::getTransactionId, transaction.getTransactionId())
|
||||||
|
.exists();
|
||||||
|
|
||||||
|
if (exists) {
|
||||||
|
log.info("Renewal already processed: transactionId={}", transaction.getTransactionId());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建续订购买记录
|
||||||
|
KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
|
||||||
|
renewRecord.setUserId(userId.intValue());
|
||||||
|
renewRecord.setProductId(product.getProductId());
|
||||||
|
renewRecord.setPurchaseQuantity(product.getDurationValue());
|
||||||
|
renewRecord.setPrice(product.getPrice());
|
||||||
|
renewRecord.setCurrency(product.getCurrency());
|
||||||
|
renewRecord.setPurchaseType(product.getType());
|
||||||
|
renewRecord.setStatus("PAID");
|
||||||
|
renewRecord.setPaymentMethod("APPLE");
|
||||||
|
renewRecord.setTransactionId(transaction.getTransactionId());
|
||||||
|
renewRecord.setOriginalTransactionId(transaction.getOriginalTransactionId());
|
||||||
|
renewRecord.setProductIds(new String[]{product.getProductId()});
|
||||||
|
|
||||||
|
if (transaction.getPurchaseDate() != null) {
|
||||||
|
renewRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||||
|
renewRecord.setPurchaseDate(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
|
||||||
|
}
|
||||||
|
|
||||||
|
Instant expiresInstant = null;
|
||||||
|
if (transaction.getExpiresDate() != null) {
|
||||||
|
expiresInstant = Instant.ofEpochMilli(transaction.getExpiresDate());
|
||||||
|
renewRecord.setExpiresDate(Date.from(expiresInstant));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transaction.getEnvironment() != null) {
|
||||||
|
renewRecord.setEnvironment(transaction.getEnvironment().name());
|
||||||
|
}
|
||||||
|
|
||||||
|
purchaseRecordsService.save(renewRecord);
|
||||||
|
|
||||||
|
// 延长VIP有效期
|
||||||
|
extendVip(userId, product, expiresInstant);
|
||||||
|
|
||||||
|
log.info("Renewal processed successfully: userId={}, transactionId={}",
|
||||||
|
userId, transaction.getTransactionId());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理订阅过期
|
||||||
|
* 检查用户VIP状态,如果已过期则更新为非VIP
|
||||||
|
*/
|
||||||
|
private void handleSubscriptionExpired(Long userId) {
|
||||||
|
KeyboardUser user = userService.getById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("User not found: userId={}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查VIP是否已过期
|
||||||
|
if (user.getVipExpiry() != null && user.getVipExpiry().toInstant().isBefore(Instant.now())) {
|
||||||
|
user.setIsVip(false);
|
||||||
|
userService.updateById(user);
|
||||||
|
log.info("User VIP expired: userId={}", userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理退款
|
||||||
|
* 撤销用户权益(订阅或钱包余额)
|
||||||
|
*/
|
||||||
|
private void handleRefund(Long userId, KeyboardProductItems product,
|
||||||
|
KeyboardUserPurchaseRecords record) {
|
||||||
|
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 订阅退款:倒扣会员时间
|
||||||
|
KeyboardUser user = userService.getById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("User not found for refund: userId={}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前VIP过期时间
|
||||||
|
Date currentVipExpiry = user.getVipExpiry();
|
||||||
|
if (currentVipExpiry == null) {
|
||||||
|
log.info("User has no VIP expiry, no need to deduct: userId={}", userId);
|
||||||
|
} else {
|
||||||
|
// 计算需要倒扣的天数
|
||||||
|
long durationDays = resolveDurationDays(product);
|
||||||
|
|
||||||
|
// 从当前过期时间倒扣
|
||||||
|
Instant currentExpiry = currentVipExpiry.toInstant();
|
||||||
|
Instant newExpiry = currentExpiry.minus(durationDays, ChronoUnit.DAYS);
|
||||||
|
|
||||||
|
// 判断倒扣后的时间是否仍大于当前时间
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (newExpiry.isAfter(now)) {
|
||||||
|
// 倒扣后仍然是VIP
|
||||||
|
user.setIsVip(true);
|
||||||
|
user.setVipExpiry(Date.from(newExpiry));
|
||||||
|
log.info("Subscription refunded, VIP time deducted: userId={}, oldExpiry={}, newExpiry={}",
|
||||||
|
userId, currentExpiry, newExpiry);
|
||||||
|
} else {
|
||||||
|
// 倒扣后已过期,取消VIP
|
||||||
|
user.setIsVip(false);
|
||||||
|
user.setVipExpiry(Date.from(newExpiry)); // 保留倒扣后的时间,而不是设为null
|
||||||
|
log.info("Subscription refunded, VIP expired after deduction: userId={}, newExpiry={}",
|
||||||
|
userId, newExpiry);
|
||||||
|
}
|
||||||
|
|
||||||
|
userService.updateById(user);
|
||||||
|
}
|
||||||
|
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 内购退款:扣除钱包余额
|
||||||
|
BigDecimal refundAmount = resolveCreditAmount(product);
|
||||||
|
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserWallet::getUserId, userId)
|
||||||
|
.one();
|
||||||
|
|
||||||
|
if (wallet != null && wallet.getBalance().compareTo(refundAmount) >= 0) {
|
||||||
|
BigDecimal before = wallet.getBalance();
|
||||||
|
BigDecimal after = before.subtract(refundAmount);
|
||||||
|
wallet.setBalance(after);
|
||||||
|
walletService.updateById(wallet);
|
||||||
|
|
||||||
|
// 创建退款交易记录
|
||||||
|
walletTransactionService.createTransaction(
|
||||||
|
userId,
|
||||||
|
record.getId().longValue(),
|
||||||
|
refundAmount.negate(),
|
||||||
|
(short) 3, // 交易类型:3-退款
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
"Apple 退款: " + product.getProductId()
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("In-app purchase refunded: userId={}, amount={}", userId, refundAmount);
|
||||||
|
} else {
|
||||||
|
log.warn("Insufficient balance for refund: userId={}, required={}", userId, refundAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新购买记录状态
|
||||||
|
record.setStatus("REFUNDED");
|
||||||
|
purchaseRecordsService.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理退款撤销
|
||||||
|
* 恢复用户权益(加回之前倒扣的时间)
|
||||||
|
*/
|
||||||
|
private void handleRefundReversed(Long userId, KeyboardProductItems product,
|
||||||
|
KeyboardUserPurchaseRecords record) {
|
||||||
|
if ("subscription".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 订阅退款撤销:加回之前倒扣的会员时间
|
||||||
|
KeyboardUser user = userService.getById(userId);
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("User not found for refund reversal: userId={}", userId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前VIP过期时间
|
||||||
|
Date currentVipExpiry = user.getVipExpiry();
|
||||||
|
if (currentVipExpiry == null) {
|
||||||
|
// 如果当前没有过期时间,使用当前时间作为基准
|
||||||
|
currentVipExpiry = Date.from(Instant.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算需要加回的天数
|
||||||
|
long durationDays = resolveDurationDays(product);
|
||||||
|
|
||||||
|
// 加回会员时间
|
||||||
|
Instant currentExpiry = currentVipExpiry.toInstant();
|
||||||
|
Instant newExpiry = currentExpiry.plus(durationDays, ChronoUnit.DAYS);
|
||||||
|
|
||||||
|
// 判断加回后的时间是否大于当前时间
|
||||||
|
Instant now = Instant.now();
|
||||||
|
if (newExpiry.isAfter(now)) {
|
||||||
|
user.setIsVip(true);
|
||||||
|
} else {
|
||||||
|
user.setIsVip(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
user.setVipExpiry(Date.from(newExpiry));
|
||||||
|
userService.updateById(user);
|
||||||
|
|
||||||
|
log.info("Refund reversed, VIP time restored: userId={}, oldExpiry={}, newExpiry={}, isVip={}",
|
||||||
|
userId, currentExpiry, newExpiry, user.getIsVip());
|
||||||
|
|
||||||
|
} else if ("in-app-purchase".equalsIgnoreCase(product.getType())) {
|
||||||
|
// 内购退款撤销:恢复钱包余额
|
||||||
|
BigDecimal creditAmount = resolveCreditAmount(product);
|
||||||
|
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||||
|
.eq(KeyboardUserWallet::getUserId, userId)
|
||||||
|
.one();
|
||||||
|
|
||||||
|
if (wallet != null) {
|
||||||
|
BigDecimal before = wallet.getBalance();
|
||||||
|
BigDecimal after = before.add(creditAmount);
|
||||||
|
wallet.setBalance(after);
|
||||||
|
walletService.updateById(wallet);
|
||||||
|
|
||||||
|
walletTransactionService.createTransaction(
|
||||||
|
userId,
|
||||||
|
record.getId().longValue(),
|
||||||
|
creditAmount,
|
||||||
|
(short) 4, // 交易类型:4-退款撤销
|
||||||
|
before,
|
||||||
|
after,
|
||||||
|
"Apple 退款撤销: " + product.getProductId()
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info("Refund reversed: userId={}, amount={}", userId, creditAmount);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新购买记录状态
|
||||||
|
record.setStatus("PAID");
|
||||||
|
purchaseRecordsService.updateById(record);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理订阅类商品的购买
|
||||||
|
* 为用户延长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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,14 +1,13 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import com.apple.itunes.storekit.client.APIException;
|
|
||||||
import com.apple.itunes.storekit.client.AppStoreServerAPIClient;
|
|
||||||
import com.apple.itunes.storekit.migration.ReceiptUtility;
|
|
||||||
import com.apple.itunes.storekit.model.Environment;
|
import com.apple.itunes.storekit.model.Environment;
|
||||||
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
import com.apple.itunes.storekit.model.JWSTransactionDecodedPayload;
|
||||||
import com.apple.itunes.storekit.model.TransactionInfoResponse;
|
import com.apple.itunes.storekit.model.NotificationTypeV2;
|
||||||
|
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
|
||||||
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
import com.apple.itunes.storekit.verification.SignedDataVerifier;
|
||||||
import com.apple.itunes.storekit.verification.VerificationException;
|
import com.apple.itunes.storekit.verification.VerificationException;
|
||||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||||
|
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||||
import com.yolo.keyborad.service.AppleReceiptService;
|
import com.yolo.keyborad.service.AppleReceiptService;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
@@ -27,100 +26,163 @@ import java.util.List;
|
|||||||
@Service
|
@Service
|
||||||
public class AppleReceiptServiceImpl implements AppleReceiptService {
|
public class AppleReceiptServiceImpl implements AppleReceiptService {
|
||||||
|
|
||||||
/**
|
|
||||||
* App Store 服务器 API 客户端
|
|
||||||
* <p>
|
|
||||||
* 用于调用 Apple App Store Server API 获取交易信息
|
|
||||||
* </p>
|
|
||||||
*/
|
|
||||||
private final AppStoreServerAPIClient client;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 签名数据验证器
|
* 签名数据验证器
|
||||||
* <p>
|
* <p>
|
||||||
* 用于验证和解码 Apple 返回的 JWS 签名数据
|
* 用于验证和解码客户端传来的 JWS 签名交易数据
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
private final SignedDataVerifier signedDataVerifier;
|
private final SignedDataVerifier signedDataVerifier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 收据工具类
|
* 苹果购买服务
|
||||||
* <p>
|
* <p>
|
||||||
* 用于解析应用收据内容,提取交易 ID 等信息
|
* 用于处理通知后的业务逻辑
|
||||||
* </p>
|
* </p>
|
||||||
*/
|
*/
|
||||||
private final ReceiptUtility receiptUtility;
|
private final ApplePurchaseService applePurchaseService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 构造函数
|
* 构造函数
|
||||||
* <p>
|
* <p>
|
||||||
* 通过构造函数注入所需的依赖组件
|
* 通过构造函数注入签名数据验证器和购买服务
|
||||||
* </p>
|
* </p>
|
||||||
* @param client App Store 服务器 API 客户端
|
|
||||||
* @param signedDataVerifier 签名数据验证器
|
* @param signedDataVerifier 签名数据验证器
|
||||||
* @param receiptUtility 收据工具类
|
* @param applePurchaseService 苹果购买服务
|
||||||
*/
|
*/
|
||||||
public AppleReceiptServiceImpl(AppStoreServerAPIClient client,
|
public AppleReceiptServiceImpl(SignedDataVerifier signedDataVerifier,
|
||||||
SignedDataVerifier signedDataVerifier,
|
ApplePurchaseService applePurchaseService) {
|
||||||
ReceiptUtility receiptUtility) {
|
|
||||||
this.client = client;
|
|
||||||
this.signedDataVerifier = signedDataVerifier;
|
this.signedDataVerifier = signedDataVerifier;
|
||||||
this.receiptUtility = receiptUtility;
|
this.applePurchaseService = applePurchaseService;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 验证 Apple 应用内购买收据
|
* 验证 Apple 应用内购买交易
|
||||||
* <p>
|
* <p>
|
||||||
* 执行完整的收据验证流程,包括解析收据、获取交易信息、验证签名和业务逻辑校验
|
* 直接验证从客户端传来的 JWS (JSON Web Signature) 交易数据
|
||||||
|
* 使用 SignedDataVerifier 验证签名并解码交易信息,然后执行业务逻辑校验
|
||||||
* </p>
|
* </p>
|
||||||
* @param appReceipt Base64 编码的应用内购买收据
|
* @param signedTransaction 客户端传来的 JWS 格式的签名交易数据
|
||||||
* @return 验证结果对象,包含验证状态和交易信息
|
* @return 验证结果对象,包含验证状态和交易信息
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public AppleReceiptValidationResult validateReceipt(String appReceipt) {
|
public AppleReceiptValidationResult validateReceipt(String signedTransaction) {
|
||||||
// 检查收据是否为空
|
// 检查 JWS 是否为空
|
||||||
if (appReceipt == null || appReceipt.isBlank()) {
|
if (signedTransaction == null || signedTransaction.isBlank()) {
|
||||||
return invalid("empty_receipt");
|
return invalid("empty_transaction");
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. 从收据里解析出 transactionId(不做验证,只是解析 ASN.1)
|
// 1. 使用 SignedDataVerifier 直接验证 JWS 并解码交易 payload
|
||||||
String transactionId = receiptUtility.extractTransactionIdFromAppReceipt(appReceipt);
|
// 这会验证签名、证书链、bundle ID、环境等
|
||||||
if (transactionId == null) {
|
|
||||||
return invalid("no_transaction_id_in_receipt");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. 调用 App Store Server API 获取单笔交易信息
|
|
||||||
TransactionInfoResponse infoResponse = client.getTransactionInfo(transactionId);
|
|
||||||
|
|
||||||
String signedTransactionInfo = infoResponse.getSignedTransactionInfo();
|
|
||||||
if (signedTransactionInfo == null) {
|
|
||||||
return invalid("no_signed_transaction_info");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 使用 SignedDataVerifier 验证 JWS 并解码 payload
|
|
||||||
JWSTransactionDecodedPayload payload =
|
JWSTransactionDecodedPayload payload =
|
||||||
signedDataVerifier.verifyAndDecodeTransaction(signedTransactionInfo);
|
signedDataVerifier.verifyAndDecodeTransaction(signedTransaction);
|
||||||
|
|
||||||
// 4. 执行业务校验:检查交易是否仍然有效
|
// 2. 记录交易信息用于调试
|
||||||
|
log.info("Verified transaction: transactionId={}, productId={}, purchaseDate={}",
|
||||||
|
payload.getTransactionId(),
|
||||||
|
payload.getProductId(),
|
||||||
|
payload.getPurchaseDate());
|
||||||
|
|
||||||
|
// 3. 执行业务校验:检查交易是否仍然有效
|
||||||
boolean stillActive = isTransactionActive(payload);
|
boolean stillActive = isTransactionActive(payload);
|
||||||
|
|
||||||
// 构建并返回验证结果
|
// 4. 构建并返回验证结果
|
||||||
return getAppleReceiptValidationResult(stillActive, payload);
|
return getAppleReceiptValidationResult(stillActive, payload);
|
||||||
|
|
||||||
} catch (VerificationException e) {
|
} catch (VerificationException e) {
|
||||||
// 验证异常处理
|
// 验证异常处理:签名无效、证书链问题、bundle ID 不匹配等
|
||||||
log.warn("Apple receipt verification failed", e);
|
log.warn("Apple transaction verification failed: status={}", e.getStatus(), e);
|
||||||
return invalid("verification_exception:" + e.getStatus());
|
return invalid("verification_exception:" + e.getStatus());
|
||||||
} catch (APIException e) {
|
|
||||||
// API 异常处理,可以根据错误码进一步处理(如环境切换)
|
|
||||||
// 4040010 表示订单不存在,可能是环境不匹配导致
|
|
||||||
long code = e.getApiError().errorCode();
|
|
||||||
log.warn("App Store API error, code={}", code, e);
|
|
||||||
return invalid("api_exception:" + code);
|
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
// 其他未预期异常处理
|
// 其他未预期异常处理
|
||||||
log.error("Unexpected error when validating Apple receipt", e);
|
log.error("Unexpected error when validating Apple transaction", e);
|
||||||
return invalid("unexpected_error");
|
return invalid("unexpected_error:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理 Apple 服务器通知
|
||||||
|
* <p>
|
||||||
|
* 验证通知签名并根据通知类型分发到相应的处理逻辑
|
||||||
|
* 支持的通知类型包括:订阅、续订、过期、退款、偏好变更等
|
||||||
|
* </p>
|
||||||
|
* @param signedPayload Apple 服务器发送的签名载荷(JWT 格式)
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public void processNotification(String signedPayload) {
|
||||||
|
if (signedPayload == null || signedPayload.isBlank()) {
|
||||||
|
log.warn("Received empty notification payload");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. 验证并解码通知载荷
|
||||||
|
ResponseBodyV2DecodedPayload notification =
|
||||||
|
signedDataVerifier.verifyAndDecodeNotification(signedPayload);
|
||||||
|
|
||||||
|
NotificationTypeV2 type = notification.getNotificationType();
|
||||||
|
log.info("Received Apple notification: type={}, subtype={}, environment={}",
|
||||||
|
type,
|
||||||
|
notification.getSubtype(),
|
||||||
|
notification.getData() != null ? notification.getData().getEnvironment() : "unknown");
|
||||||
|
|
||||||
|
// 2. 根据通知类型分发处理
|
||||||
|
if (type == null) {
|
||||||
|
log.warn("Notification type is null, ignoring");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
// 订阅相关通知
|
||||||
|
case SUBSCRIBED:
|
||||||
|
case DID_RENEW:
|
||||||
|
case DID_FAIL_TO_RENEW:
|
||||||
|
case EXPIRED:
|
||||||
|
case GRACE_PERIOD_EXPIRED:
|
||||||
|
case OFFER_REDEEMED:
|
||||||
|
case RENEWAL_EXTENDED:
|
||||||
|
case RENEWAL_EXTENSION:
|
||||||
|
applePurchaseService.handleSubscriptionNotification(notification);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 退款相关通知
|
||||||
|
case REFUND:
|
||||||
|
case REFUND_DECLINED:
|
||||||
|
case REFUND_REVERSED:
|
||||||
|
applePurchaseService.handleRefundNotification(notification);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 续订偏好变更通知
|
||||||
|
case DID_CHANGE_RENEWAL_PREF:
|
||||||
|
case DID_CHANGE_RENEWAL_STATUS:
|
||||||
|
case PRICE_INCREASE:
|
||||||
|
applePurchaseService.handleRenewalPreferenceChange(notification);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 消费请求通知
|
||||||
|
case CONSUMPTION_REQUEST:
|
||||||
|
applePurchaseService.handleConsumptionRequest(notification);
|
||||||
|
break;
|
||||||
|
|
||||||
|
// 其他通知类型(记录但不处理)
|
||||||
|
case EXTERNAL_PURCHASE_TOKEN:
|
||||||
|
case ONE_TIME_CHARGE:
|
||||||
|
case REVOKE:
|
||||||
|
case TEST:
|
||||||
|
log.info("Received notification type {} - no action required", type);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
log.warn("Unknown notification type: {}", type);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (VerificationException e) {
|
||||||
|
// 验证异常处理:签名无效、证书链问题等
|
||||||
|
log.error("Apple notification verification failed: status={}", e.getStatus(), e);
|
||||||
|
} catch (Exception e) {
|
||||||
|
// 其他未预期异常处理
|
||||||
|
log.error("Unexpected error when processing Apple notification", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,6 +255,7 @@ public class AppleReceiptServiceImpl implements AppleReceiptService {
|
|||||||
// 有 expiresDate 的一般是订阅(自动续订等),过期就无效
|
// 有 expiresDate 的一般是订阅(自动续订等),过期就无效
|
||||||
if (payload.getExpiresDate() != null) {
|
if (payload.getExpiresDate() != null) {
|
||||||
long now = System.currentTimeMillis();
|
long now = System.currentTimeMillis();
|
||||||
|
log.error("Now Date:{} ,Expires Date: {}",now, payload.getExpiresDate());
|
||||||
return now < payload.getExpiresDate();
|
return now < payload.getExpiresDate();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,11 +11,11 @@ import com.yolo.keyborad.exception.BusinessException;
|
|||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
import com.yolo.keyborad.service.IAppleService;
|
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 com.yolo.keyborad.service.UserService;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import io.jsonwebtoken.*;
|
import io.jsonwebtoken.*;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.AllArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
@@ -40,13 +40,17 @@ public class AppleServiceImpl implements IAppleService {
|
|||||||
@Resource
|
@Resource
|
||||||
private UserService userService;
|
private UserService userService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserLoginLogService loginLogService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 登录
|
* 登录
|
||||||
*
|
*
|
||||||
* @param identityToken JWT身份令牌
|
* @param identityToken JWT身份令牌
|
||||||
|
* @param request HTTP请求
|
||||||
*/
|
*/
|
||||||
@Override
|
@Override
|
||||||
public KeyboardUserRespVO login(String identityToken) throws Exception {
|
public KeyboardUserRespVO login(String identityToken, HttpServletRequest request) throws Exception {
|
||||||
|
|
||||||
// 1. 清理一下 token,防止前后多了引号/空格
|
// 1. 清理一下 token,防止前后多了引号/空格
|
||||||
identityToken = identityToken.trim();
|
identityToken = identityToken.trim();
|
||||||
@@ -88,13 +92,51 @@ public class AppleServiceImpl implements IAppleService {
|
|||||||
// 返回用户标识符
|
// 返回用户标识符
|
||||||
if (result) {
|
if (result) {
|
||||||
KeyboardUser user = userService.selectUserWithSubjectId(sub);
|
KeyboardUser user = userService.selectUserWithSubjectId(sub);
|
||||||
|
boolean isNewUser = false;
|
||||||
if (user == null) {
|
if (user == null) {
|
||||||
KeyboardUser newUser = userService.createUserWithSubjectId(sub);
|
user = userService.createUserWithSubjectId(sub);
|
||||||
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(newUser, KeyboardUserRespVO.class);
|
isNewUser = true;
|
||||||
StpUtil.login(newUser.getId());
|
|
||||||
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(newUser.getId()));
|
|
||||||
return keyboardUserRespVO;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 记录登录日志
|
||||||
|
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);
|
KeyboardUserRespVO keyboardUserRespVO = BeanUtil.copyProperties(user, KeyboardUserRespVO.class);
|
||||||
StpUtil.login(user.getId());
|
StpUtil.login(user.getId());
|
||||||
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
|
keyboardUserRespVO.setToken(StpUtil.getTokenValueByLoginId(user.getId()));
|
||||||
|
|||||||
@@ -1,12 +1,324 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import com.yolo.keyborad.service.ChatService;
|
import cn.dev33.satoken.context.mock.SaTokenContextMockUtil;
|
||||||
|
import cn.dev33.satoken.stp.StpUtil;
|
||||||
|
import cn.hutool.core.util.IdUtil;
|
||||||
|
import cn.hutool.core.util.StrUtil;
|
||||||
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.AppConfig;
|
||||||
|
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||||
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatReq;
|
||||||
|
import com.yolo.keyborad.model.dto.chat.ChatStreamMessage;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserCallLog;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
|
||||||
|
import com.yolo.keyborad.service.*;
|
||||||
|
import jakarta.annotation.Resource;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.ai.chat.client.ChatClient;
|
||||||
|
import org.springframework.ai.openai.OpenAiChatOptions;
|
||||||
|
import org.springframework.http.codec.ServerSentEvent;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
|
import reactor.core.publisher.Mono;
|
||||||
|
import reactor.core.scheduler.Schedulers;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
* @date: 2025/12/8 15:17
|
* @date: 2025/12/8 15:17
|
||||||
*/
|
*/
|
||||||
@Service
|
@Service
|
||||||
|
@Slf4j
|
||||||
public class ChatServiceImpl implements ChatService {
|
public class ChatServiceImpl implements ChatService {
|
||||||
|
|
||||||
|
// 最大消息长度限制
|
||||||
|
private static final int MAX_MESSAGE_LENGTH = 1000;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private ChatClient client;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private QdrantVectorService qdrantVectorService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardCharacterService keyboardCharacterService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserCallLogService callLogService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserQuotaTotalService quotaTotalService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
|
public ChatServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||||
|
this.cfgHolder = cfgHolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理聊天对话,返回流式响应
|
||||||
|
*
|
||||||
|
* @param chatReq 聊天请求对象,包含角色ID和消息内容
|
||||||
|
* @return 返回SSE流式事件,包含LLM响应、向量搜索结果和结束标记
|
||||||
|
* @throws BusinessException 当参数校验失败或角色不存在时抛出
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public Flux<ServerSentEvent<ChatStreamMessage>> talk(ChatReq chatReq) {
|
||||||
|
AppConfig appConfig = cfgHolder.getRef().get();
|
||||||
|
|
||||||
|
// ============ 1. 参数校验 ============
|
||||||
|
// 验证请求对象非空
|
||||||
|
if (chatReq == null) {
|
||||||
|
log.error("聊天请求参数为空");
|
||||||
|
throw new BusinessException(ErrorCode.PARAMS_ERROR);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证键盘人设ID非空
|
||||||
|
if (chatReq.getCharacterId() == null) {
|
||||||
|
log.error("键盘人设ID为空");
|
||||||
|
throw new BusinessException(ErrorCode.CHAT_CHARACTER_ID_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证消息内容非空
|
||||||
|
if (StrUtil.isBlank(chatReq.getMessage())) {
|
||||||
|
log.error("聊天消息为空");
|
||||||
|
throw new BusinessException(ErrorCode.CHAT_MESSAGE_EMPTY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证消息长度不超过限制
|
||||||
|
if (chatReq.getMessage().length() > appConfig.getLlmConfig().getMaxMessageLength()) {
|
||||||
|
log.error("聊天消息过长,长度: {}", chatReq.getMessage().length());
|
||||||
|
throw new BusinessException(ErrorCode.CHAT_MESSAGE_TOO_LONG);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 2. 验证键盘人设是否存在 ============
|
||||||
|
KeyboardCharacter character = keyboardCharacterService.getById(chatReq.getCharacterId());
|
||||||
|
if (character == null) {
|
||||||
|
log.error("键盘人设不存在,ID: {}", chatReq.getCharacterId());
|
||||||
|
throw new BusinessException(ErrorCode.CHAT_CHARACTER_NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ 3. 校验用户免费次数和VIP ============
|
||||||
|
|
||||||
|
// ============ 获取用户ID ============
|
||||||
|
Long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
|
||||||
|
// ============ 查询用户配额信息 ============
|
||||||
|
KeyboardUserQuotaTotal quota = quotaTotalService.getById(userId);
|
||||||
|
// 判断是否有剩余免费次数
|
||||||
|
boolean hasFreeQuota = quota != null && quota.getUsedQuota() < quota.getTotalQuota();
|
||||||
|
// 使用原子引用记录是否使用了免费次数(用于后续扣减)
|
||||||
|
AtomicReference<Boolean> usedFreeQuota = new AtomicReference<>(hasFreeQuota);
|
||||||
|
|
||||||
|
// ============ 如果没有免费次数,校验VIP权限 ============
|
||||||
|
if (!hasFreeQuota) {
|
||||||
|
// 查询用户信息
|
||||||
|
KeyboardUser user = userService.getById(userId);
|
||||||
|
// 判断是否为有效VIP:
|
||||||
|
// 1. 用户存在
|
||||||
|
// 2. VIP标识为true
|
||||||
|
// 3. VIP到期时间为空(永久VIP)或未过期
|
||||||
|
boolean isValidVip = user != null && user.getIsVip() != null && user.getIsVip()
|
||||||
|
&& (user.getVipExpiry() == null || user.getVipExpiry().after(new Date()));
|
||||||
|
// 如果既无免费次数又非VIP,则抛出异常
|
||||||
|
if (!isValidVip) {
|
||||||
|
log.error("用户无免费次数且非VIP,用户ID: {}", userId);
|
||||||
|
throw new BusinessException(ErrorCode.NO_QUOTA_AND_NOT_VIP);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取应用配置
|
||||||
|
|
||||||
|
// ============ 初始化调用日志相关变量 ============
|
||||||
|
// 生成唯一请求ID用于日志追踪
|
||||||
|
String requestId = IdUtil.fastSimpleUUID();
|
||||||
|
// 记录开始时间用于计算延迟
|
||||||
|
long startTime = System.currentTimeMillis();
|
||||||
|
// 原子引用保存模型信息
|
||||||
|
AtomicReference<String> modelRef = new AtomicReference<>();
|
||||||
|
// 原子计数器保存输入token数
|
||||||
|
AtomicInteger inputTokens = new AtomicInteger(0);
|
||||||
|
// 原子计数器保存输出token数
|
||||||
|
AtomicInteger outputTokens = new AtomicInteger(0);
|
||||||
|
// 原子引用保存错误代码
|
||||||
|
AtomicReference<String> errorCodeRef = new AtomicReference<>();
|
||||||
|
//原子引用保存生成ID
|
||||||
|
AtomicReference<String> genId = new AtomicReference<>();
|
||||||
|
|
||||||
|
// ============ 3. 构建LLM流式输出 ============
|
||||||
|
Flux<ChatStreamMessage> llmFlux = client
|
||||||
|
// 设置角色提示词
|
||||||
|
.prompt(character.getPrompt())
|
||||||
|
// 设置系统提示词
|
||||||
|
.system(appConfig.getLlmConfig().getSystemPrompt())
|
||||||
|
// 设置用户消息
|
||||||
|
.user(chatReq.getMessage())
|
||||||
|
// 配置OpenAI选项
|
||||||
|
.options(OpenAiChatOptions.builder()
|
||||||
|
.user(StpUtil.getLoginIdAsString())
|
||||||
|
.build())
|
||||||
|
// 启用流式响应
|
||||||
|
.stream()
|
||||||
|
.chatResponse()
|
||||||
|
// 处理每个响应块
|
||||||
|
.concatMap(response -> {
|
||||||
|
// ===== 提取并保存元数据信息 =====
|
||||||
|
if (response.getMetadata() != null) {
|
||||||
|
var metadata = response.getMetadata();
|
||||||
|
// 保存模型名称
|
||||||
|
if (metadata.getModel() != null) {
|
||||||
|
modelRef.set(metadata.getModel());
|
||||||
|
}
|
||||||
|
if (metadata.getId() != null){
|
||||||
|
genId.set(metadata.getId());
|
||||||
|
}
|
||||||
|
// 保存token使用情况
|
||||||
|
if (metadata.getUsage() != null) {
|
||||||
|
var usage = metadata.getUsage();
|
||||||
|
if (usage.getPromptTokens() != null) {
|
||||||
|
inputTokens.set(usage.getPromptTokens());
|
||||||
|
}
|
||||||
|
if (usage.getCompletionTokens() != null) {
|
||||||
|
outputTokens.set(usage.getCompletionTokens());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 获取响应内容 =====
|
||||||
|
String content = response.getResult().getOutput().getText();
|
||||||
|
// 如果内容为空则跳过
|
||||||
|
if (content == null || content.isEmpty()) {
|
||||||
|
return Flux.empty();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 将内容拆分为单个字符(支持Unicode) =====
|
||||||
|
List<String> chars = content.codePoints()
|
||||||
|
.mapToObj(cp -> new String(Character.toChars(cp)))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// ===== 按3个字符批量发送(优化传输效率) =====
|
||||||
|
List<String> batched = new ArrayList<>();
|
||||||
|
StringBuilder sb = new StringBuilder();
|
||||||
|
for (String ch : chars) {
|
||||||
|
sb.append(ch);
|
||||||
|
// 每累积3个字符就添加到批次列表
|
||||||
|
if (sb.length() >= 3) {
|
||||||
|
batched.add(sb.toString());
|
||||||
|
sb.setLength(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 添加剩余字符
|
||||||
|
if (!sb.isEmpty()) {
|
||||||
|
batched.add(sb.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 将批次转换为消息流 =====
|
||||||
|
return Flux.fromIterable(batched)
|
||||||
|
.map(s -> new ChatStreamMessage("llm_chunk", s));
|
||||||
|
})
|
||||||
|
// 记录LLM调用错误
|
||||||
|
.doOnError(error -> {
|
||||||
|
log.error("LLM调用失败", error);
|
||||||
|
errorCodeRef.set("LLM_ERROR");
|
||||||
|
})
|
||||||
|
// 错误恢复:返回友好提示消息
|
||||||
|
.onErrorResume(error ->
|
||||||
|
Flux.just(new ChatStreamMessage("error", "LLM服务暂时不可用,请稍后重试"))
|
||||||
|
);
|
||||||
|
|
||||||
|
// ============ 4. 构建向量搜索Flux(一次性发送搜索结果) ============
|
||||||
|
Flux<ChatStreamMessage> searchFlux = Mono
|
||||||
|
// 异步执行向量搜索
|
||||||
|
.fromCallable(() -> qdrantVectorService.searchText(chatReq.getMessage()))
|
||||||
|
// 使用独立线程池避免阻塞事件循环
|
||||||
|
.subscribeOn(Schedulers.boundedElastic())
|
||||||
|
// 将搜索结果包装为消息
|
||||||
|
.map(list -> new ChatStreamMessage("search_result", list))
|
||||||
|
// 记录搜索失败日志
|
||||||
|
.doOnError(error -> log.error("向量搜索失败", error))
|
||||||
|
// 错误恢复:返回空列表
|
||||||
|
.onErrorResume(error ->
|
||||||
|
Mono.just(new ChatStreamMessage("search_result", new ArrayList<>()))
|
||||||
|
)
|
||||||
|
// 转换为Flux流
|
||||||
|
.flux();
|
||||||
|
|
||||||
|
// ============ 5. 构建结束标记 ============
|
||||||
|
Flux<ChatStreamMessage> doneFlux =
|
||||||
|
Flux.just(new ChatStreamMessage("done", null));
|
||||||
|
|
||||||
|
// ============ 6. 合并所有Flux流 ============
|
||||||
|
// merge: LLM响应和向量搜索并行执行
|
||||||
|
// concatWith: 在流结束后添加结束标记
|
||||||
|
Flux<ChatStreamMessage> merged =
|
||||||
|
Flux.merge(llmFlux, searchFlux)
|
||||||
|
.concatWith(doneFlux);
|
||||||
|
|
||||||
|
// 保存当前用户token值,用于异步日志记录
|
||||||
|
String tokenValue = StpUtil.getTokenValue();
|
||||||
|
|
||||||
|
// ============ 7. SSE包装并记录调用日志 ============
|
||||||
|
return merged
|
||||||
|
// 在流完成/取消/出错时执行
|
||||||
|
.doFinally(signalType -> {
|
||||||
|
// ===== 异步保存调用日志 =====
|
||||||
|
Mono.fromRunnable(() -> {
|
||||||
|
try {
|
||||||
|
// 构建调用日志对象
|
||||||
|
KeyboardUserCallLog callLog = new KeyboardUserCallLog();
|
||||||
|
// 设置Mock上下文以获取用户ID
|
||||||
|
SaTokenContextMockUtil.setMockContext(() -> {
|
||||||
|
StpUtil.setTokenValueToStorage(tokenValue);
|
||||||
|
callLog.setUserId(StpUtil.getLoginIdAsLong());
|
||||||
|
});
|
||||||
|
// 设置日志基本信息
|
||||||
|
callLog.setRequestId(requestId);
|
||||||
|
callLog.setGenId(genId.get());
|
||||||
|
callLog.setFeature("chat_talk");
|
||||||
|
callLog.setModel(modelRef.get());
|
||||||
|
// 设置token使用情况
|
||||||
|
callLog.setInputTokens(inputTokens.get());
|
||||||
|
callLog.setOutputTokens(outputTokens.get());
|
||||||
|
callLog.setTotalTokens(inputTokens.get() + outputTokens.get());
|
||||||
|
// 设置调用结果(无错误码表示成功)
|
||||||
|
callLog.setSuccess(errorCodeRef.get() == null);
|
||||||
|
// 计算并设置延迟时间(毫秒)
|
||||||
|
callLog.setLatencyMs((int) (System.currentTimeMillis() - startTime));
|
||||||
|
callLog.setErrorCode(errorCodeRef.get());
|
||||||
|
callLog.setCreatedAt(new Date());
|
||||||
|
// 保存日志到数据库
|
||||||
|
callLogService.save(callLog);
|
||||||
|
|
||||||
|
// 如果使用了免费次数且调用成功,扣减免费次数
|
||||||
|
if (usedFreeQuota.get() && callLog.getSuccess()) {
|
||||||
|
KeyboardUserQuotaTotal updateQuota = new KeyboardUserQuotaTotal();
|
||||||
|
updateQuota.setUserId(callLog.getUserId());
|
||||||
|
updateQuota.setUsedQuota(quota.getUsedQuota() + 1);
|
||||||
|
quotaTotalService.updateById(updateQuota);
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("保存调用日志失败", e);
|
||||||
|
}
|
||||||
|
}).subscribeOn(Schedulers.boundedElastic()).subscribe();
|
||||||
|
})
|
||||||
|
// 将消息包装为SSE事件
|
||||||
|
.map(msg ->
|
||||||
|
ServerSentEvent.builder(msg)
|
||||||
|
.event(msg.getType())
|
||||||
|
.build()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import com.yolo.keyborad.model.entity.KeyboardUserCharacter;
|
|||||||
import com.yolo.keyborad.model.vo.character.KeyboardCharacterRespVO;
|
import com.yolo.keyborad.model.vo.character.KeyboardCharacterRespVO;
|
||||||
import com.yolo.keyborad.model.vo.character.KeyboardUserCharacterVO;
|
import com.yolo.keyborad.model.vo.character.KeyboardUserCharacterVO;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
|
import org.springframework.data.redis.core.RedisTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
||||||
@@ -27,6 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.concurrent.TimeUnit;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
/*
|
/*
|
||||||
* @author: ziin
|
* @author: ziin
|
||||||
@@ -36,6 +38,8 @@ import java.util.stream.Stream;
|
|||||||
@Service
|
@Service
|
||||||
public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterMapper, KeyboardCharacter> implements KeyboardCharacterService{
|
public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterMapper, KeyboardCharacter> implements KeyboardCharacterService{
|
||||||
|
|
||||||
|
private static final String CHARACTER_CACHE_KEY = "character:";
|
||||||
|
|
||||||
@Resource
|
@Resource
|
||||||
private KeyboardCharacterMapper keyboardCharacterMapper;
|
private KeyboardCharacterMapper keyboardCharacterMapper;
|
||||||
|
|
||||||
@@ -45,41 +49,111 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
@Resource
|
@Resource
|
||||||
private KeyboardUserSortMapper keyboardUserSortMapper;
|
private KeyboardUserSortMapper keyboardUserSortMapper;
|
||||||
|
|
||||||
|
@Resource(name = "objectRedisTemplate")
|
||||||
|
private RedisTemplate<String, Object> redisTemplate;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<KeyboardCharacterRespVO> selectListWithRank() {
|
public KeyboardCharacter getById(java.io.Serializable id) {
|
||||||
long userId = StpUtil.getLoginIdAsLong();
|
String key = CHARACTER_CACHE_KEY + id;
|
||||||
List<KeyboardCharacter> keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
KeyboardCharacter character = (KeyboardCharacter) redisTemplate.opsForValue().get(key);
|
||||||
.eq(KeyboardCharacter::getDeleted, false)
|
if (character == null) {
|
||||||
.orderByAsc(KeyboardCharacter::getRank));
|
character = super.getById(id);
|
||||||
|
if (character != null) {
|
||||||
List<KeyboardUserCharacterVO> userCharacterVOList = keyboardUserCharacterMapper.selectByUserId(userId);
|
redisTemplate.opsForValue().set(key, character, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
List<KeyboardCharacterRespVO> keyboardCharacterRespVOS = BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
}
|
||||||
|
return character;
|
||||||
keyboardCharacterRespVOS.forEach(character -> {
|
|
||||||
character.setAdded(userCharacterVOList.stream().anyMatch(userCharacter ->
|
|
||||||
userCharacter.getCharacterId().equals(character.getId())));
|
|
||||||
});
|
|
||||||
return keyboardCharacterRespVOS;
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public List<KeyboardCharacterRespVO> selectListByTag(Long tagId) {
|
|
||||||
List<KeyboardCharacter> keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
|
||||||
.eq(KeyboardCharacter::getDeleted, false)
|
|
||||||
.eq(KeyboardCharacter::getTag, tagId)
|
|
||||||
.orderByDesc(KeyboardCharacter::getRank));
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询所有人设列表并标记用户是否已添加
|
||||||
|
*
|
||||||
|
* @return 人设响应列表,包含是否已添加的标记
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<KeyboardCharacterRespVO> selectListWithRank() {
|
||||||
|
// 获取当前登录用户ID
|
||||||
long userId = StpUtil.getLoginIdAsLong();
|
long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
|
||||||
|
// 定义缓存key,用于存储所有人设列表
|
||||||
|
String cacheKey = "character:list:all";
|
||||||
|
// 尝试从Redis缓存中获取人设列表
|
||||||
|
List<KeyboardCharacter> keyboardCharacters = (List<KeyboardCharacter>) redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
|
||||||
|
// 如果缓存中没有数据
|
||||||
|
if (keyboardCharacters == null) {
|
||||||
|
// 从数据库查询所有未删除的人设,按rank升序排列
|
||||||
|
keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
||||||
|
.eq(KeyboardCharacter::getDeleted, false)
|
||||||
|
.orderByAsc(KeyboardCharacter::getRank));
|
||||||
|
|
||||||
|
// 将查询结果缓存到Redis,设置7天过期时间
|
||||||
|
if (keyboardCharacters != null && !keyboardCharacters.isEmpty()) {
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, keyboardCharacters, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询当前用户已添加的人设列表
|
||||||
List<KeyboardUserCharacterVO> userCharacterVOList = keyboardUserCharacterMapper.selectByUserId(userId);
|
List<KeyboardUserCharacterVO> userCharacterVOList = keyboardUserCharacterMapper.selectByUserId(userId);
|
||||||
|
|
||||||
|
// 将KeyboardCharacter实体列表转换为KeyboardCharacterRespVO响应对象列表
|
||||||
List<KeyboardCharacterRespVO> keyboardCharacterRespVOS = BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
List<KeyboardCharacterRespVO> keyboardCharacterRespVOS = BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
||||||
|
|
||||||
|
// 遍历人设列表,标记每个人设是否已被当前用户添加
|
||||||
keyboardCharacterRespVOS.forEach(character -> {
|
keyboardCharacterRespVOS.forEach(character -> {
|
||||||
|
// 检查用户已添加列表中是否存在该人设ID
|
||||||
character.setAdded(userCharacterVOList.stream().anyMatch(userCharacter ->
|
character.setAdded(userCharacterVOList.stream().anyMatch(userCharacter ->
|
||||||
userCharacter.getCharacterId().equals(character.getId())));
|
userCharacter.getCharacterId().equals(character.getId())));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return keyboardCharacterRespVOS;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据标签ID查询人设列表并标记用户是否已添加
|
||||||
|
*
|
||||||
|
* @param tagId 标签ID
|
||||||
|
* @return 人设响应列表,包含是否已添加的标记
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<KeyboardCharacterRespVO> selectListByTag(Long tagId) {
|
||||||
|
// 构建缓存key,用于存储指定标签的人设列表
|
||||||
|
String cacheKey = "character:list:tag:" + tagId;
|
||||||
|
// 尝试从Redis缓存中获取指定标签的人设列表
|
||||||
|
List<KeyboardCharacter> keyboardCharacters = (List<KeyboardCharacter>) redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
|
||||||
|
// 如果缓存中没有数据
|
||||||
|
if (keyboardCharacters == null) {
|
||||||
|
// 从数据库查询指定标签的未删除人设,按rank降序排列
|
||||||
|
keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
||||||
|
.eq(KeyboardCharacter::getDeleted, false)
|
||||||
|
.eq(KeyboardCharacter::getTag, tagId)
|
||||||
|
.orderByDesc(KeyboardCharacter::getRank));
|
||||||
|
|
||||||
|
// 将查询结果缓存到Redis,设置7天过期时间
|
||||||
|
if (keyboardCharacters != null && !keyboardCharacters.isEmpty()) {
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, keyboardCharacters, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前登录用户ID
|
||||||
|
long userId = StpUtil.getLoginIdAsLong();
|
||||||
|
|
||||||
|
// 查询当前用户已添加的人设列表
|
||||||
|
List<KeyboardUserCharacterVO> userCharacterVOList = keyboardUserCharacterMapper.selectByUserId(userId);
|
||||||
|
|
||||||
|
// 将KeyboardCharacter实体列表转换为KeyboardCharacterRespVO响应对象列表
|
||||||
|
List<KeyboardCharacterRespVO> keyboardCharacterRespVOS = BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
||||||
|
|
||||||
|
// 遍历人设列表,标记每个人设是否已被当前用户添加
|
||||||
|
keyboardCharacterRespVOS.forEach(character -> {
|
||||||
|
// 检查用户已添加列表中是否存在该人设ID
|
||||||
|
character.setAdded(userCharacterVOList.stream().anyMatch(userCharacter ->
|
||||||
|
userCharacter.getCharacterId().equals(character.getId())));
|
||||||
|
});
|
||||||
|
|
||||||
return keyboardCharacterRespVOS;
|
return keyboardCharacterRespVOS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,29 +226,66 @@ public class KeyboardCharacterServiceImpl extends ServiceImpl<KeyboardCharacterM
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<KeyboardCharacterRespVO> selectListWithNotLoginRank() {
|
public List<KeyboardCharacterRespVO> selectListWithNotLoginRank() {
|
||||||
List<KeyboardCharacter> keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
// 先从缓存获取所有人设列表
|
||||||
.eq(KeyboardCharacter::getDeleted, false)
|
String cacheKey = "character:list:all";
|
||||||
.orderByAsc(KeyboardCharacter::getRank));
|
List<KeyboardCharacter> keyboardCharacters = (List<KeyboardCharacter>) redisTemplate.opsForValue().get(cacheKey);
|
||||||
|
|
||||||
|
if (keyboardCharacters == null) {
|
||||||
|
// 缓存未命中,从数据库查询
|
||||||
|
keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
||||||
|
.eq(KeyboardCharacter::getDeleted, false)
|
||||||
|
.orderByAsc(KeyboardCharacter::getRank));
|
||||||
|
// 缓存到Redis,7天过期
|
||||||
|
if (keyboardCharacters != null && !keyboardCharacters.isEmpty()) {
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, keyboardCharacters, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
return BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
return BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId) {
|
public List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId) {
|
||||||
List<KeyboardCharacter> keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
// 先从缓存获取指定标签的人设列表
|
||||||
.eq(KeyboardCharacter::getDeleted, false)
|
String cacheKey = "character:list:tag:" + tagId;
|
||||||
.eq(KeyboardCharacter::getTag, tagId)
|
List<KeyboardCharacter> keyboardCharacters = (List<KeyboardCharacter>) redisTemplate.opsForValue().get(cacheKey);
|
||||||
.orderByDesc(KeyboardCharacter::getRank));
|
|
||||||
|
if (keyboardCharacters == null) {
|
||||||
|
// 缓存未命中,从数据库查询
|
||||||
|
keyboardCharacters = keyboardCharacterMapper.selectList(new LambdaQueryWrapper<KeyboardCharacter>()
|
||||||
|
.eq(KeyboardCharacter::getDeleted, false)
|
||||||
|
.eq(KeyboardCharacter::getTag, tagId)
|
||||||
|
.orderByDesc(KeyboardCharacter::getRank));
|
||||||
|
// 缓存到Redis,7天过期
|
||||||
|
if (keyboardCharacters != null && !keyboardCharacters.isEmpty()) {
|
||||||
|
redisTemplate.opsForValue().set(cacheKey, keyboardCharacters, 7, TimeUnit.DAYS);
|
||||||
|
}
|
||||||
|
}
|
||||||
return BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
return BeanUtil.copyToList(keyboardCharacters, KeyboardCharacterRespVO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 为新用户添加默认人设
|
||||||
|
* 从所有人设列表中选取前5个,自动添加到用户的人设列表中
|
||||||
|
*
|
||||||
|
* @param userId 用户ID
|
||||||
|
*/
|
||||||
@Override
|
@Override
|
||||||
public void addDefaultUserCharacter(Long userId) {
|
public void addDefaultUserCharacter(Long userId) {
|
||||||
|
// 创建用户人设添加DTO对象,用于复用添加逻辑
|
||||||
KeyboardUserCharacterAddDTO keyboardUserCharacterAddDTO = new KeyboardUserCharacterAddDTO();
|
KeyboardUserCharacterAddDTO keyboardUserCharacterAddDTO = new KeyboardUserCharacterAddDTO();
|
||||||
|
|
||||||
|
// 获取所有人设列表(未登录状态),并限制取前5个
|
||||||
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank().stream().limit(5);
|
Stream<KeyboardCharacterRespVO> limit = selectListWithNotLoginRank().stream().limit(5);
|
||||||
|
|
||||||
|
// 遍历前5个人设,为用户添加默认人设
|
||||||
limit.forEach(character -> {
|
limit.forEach(character -> {
|
||||||
|
// 设置人设ID
|
||||||
keyboardUserCharacterAddDTO.setCharacterId(character.getId());
|
keyboardUserCharacterAddDTO.setCharacterId(character.getId());
|
||||||
|
// 设置人设表情符号
|
||||||
keyboardUserCharacterAddDTO.setEmoji(character.getEmoji());
|
keyboardUserCharacterAddDTO.setEmoji(character.getEmoji());
|
||||||
this.addUserCharacter(keyboardUserCharacterAddDTO,userId);
|
// 调用添加用户人设的方法
|
||||||
|
this.addUserCharacter(keyboardUserCharacterAddDTO, userId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.KeyboardFeedbackMapper;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||||
|
import com.yolo.keyborad.service.KeyboardFeedbackService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 17:06
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class KeyboardFeedbackServiceImpl extends ServiceImpl<KeyboardFeedbackMapper, KeyboardFeedback> implements KeyboardFeedbackService{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import cn.hutool.core.bean.BeanUtil;
|
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.model.entity.KeyboardThemePurchase;
|
||||||
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
|
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
@@ -132,15 +133,23 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
|
|||||||
.map(KeyboardThemePurchase::getThemeId)
|
.map(KeyboardThemePurchase::getThemeId)
|
||||||
.collect(Collectors.toSet());
|
.collect(Collectors.toSet());
|
||||||
|
|
||||||
// 查询推荐主题列表:未删除、已启用、未购买、按真实下载量降序、限制8条
|
// 构建查询器
|
||||||
return this.lambdaQuery()
|
LambdaQueryChainWrapper<KeyboardThemes> queryWrapper = this.lambdaQuery()
|
||||||
.eq(KeyboardThemes::getDeleted, false)
|
.eq(KeyboardThemes::getDeleted, false)
|
||||||
.eq(KeyboardThemes::getThemeStatus, true)
|
.eq(KeyboardThemes::getThemeStatus, true)
|
||||||
.notIn(!purchasedThemeIds.isEmpty(), KeyboardThemes::getId, purchasedThemeIds)
|
.orderByDesc(KeyboardThemes::getRealDownloadCount);
|
||||||
.orderByDesc(KeyboardThemes::getRealDownloadCount)
|
|
||||||
.last("LIMIT 8")
|
// 如果有已购买的主题,排除它们
|
||||||
.list()
|
if (!purchasedThemeIds.isEmpty()) {
|
||||||
.stream()
|
queryWrapper.notIn(KeyboardThemes::getId, purchasedThemeIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询推荐主题列表,限制8条
|
||||||
|
List<KeyboardThemes> themesList = queryWrapper.list();
|
||||||
|
|
||||||
|
// 只取前8条数据
|
||||||
|
return themesList.stream()
|
||||||
|
.limit(8)
|
||||||
.map(theme -> {
|
.map(theme -> {
|
||||||
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
|
KeyboardThemesRespVO vo = BeanUtil.copyProperties(theme, KeyboardThemesRespVO.class);
|
||||||
// 推荐列表中的主题均为未购买状态
|
// 推荐列表中的主题均为未购买状态
|
||||||
@@ -148,4 +157,30 @@ public class KeyboardThemesServiceImpl extends ServiceImpl<KeyboardThemesMapper,
|
|||||||
return vo;
|
return vo;
|
||||||
}).collect(Collectors.toList());
|
}).collect(Collectors.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public List<KeyboardThemesRespVO> searchThemesByName(String themeName, Long userId) {
|
||||||
|
// 根据主题名称模糊搜索
|
||||||
|
List<KeyboardThemes> themesList = this.lambdaQuery()
|
||||||
|
.eq(KeyboardThemes::getDeleted, false)
|
||||||
|
.eq(KeyboardThemes::getThemeStatus, true)
|
||||||
|
.like(KeyboardThemes::getThemeName, themeName)
|
||||||
|
.list();
|
||||||
|
|
||||||
|
// 查询用户已购买的主题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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.model.entity.KeyboardUserCallLog;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardUserCallLogMapper;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserCallLogService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/17 13:29
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class KeyboardUserCallLogServiceImpl extends ServiceImpl<KeyboardUserCallLogMapper, KeyboardUserCallLog> implements KeyboardUserCallLogService{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -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.model.entity.KeyboardUserQuotaTotal;
|
||||||
|
import com.yolo.keyborad.mapper.KeyboardUserQuotaTotalMapper;
|
||||||
|
import com.yolo.keyborad.service.KeyboardUserQuotaTotalService;
|
||||||
|
/*
|
||||||
|
* @author: ziin
|
||||||
|
* @date: 2025/12/16 16:00
|
||||||
|
*/
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class KeyboardUserQuotaTotalServiceImpl extends ServiceImpl<KeyboardUserQuotaTotalMapper, KeyboardUserQuotaTotal> implements KeyboardUserQuotaTotalService{
|
||||||
|
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
package com.yolo.keyborad.service.impl;
|
package com.yolo.keyborad.service.impl;
|
||||||
|
|
||||||
import com.google.common.primitives.Floats;
|
import com.google.common.primitives.Floats;
|
||||||
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.AppConfig;
|
||||||
|
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
import com.yolo.keyborad.model.vo.QdrantSearchItem;
|
import com.yolo.keyborad.model.vo.QdrantSearchItem;
|
||||||
import io.qdrant.client.QdrantClient;
|
import io.qdrant.client.QdrantClient;
|
||||||
@@ -34,6 +37,11 @@ public class QdrantVectorService {
|
|||||||
@Resource
|
@Resource
|
||||||
private EmbeddingModel embeddingModel;
|
private EmbeddingModel embeddingModel;
|
||||||
|
|
||||||
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
|
public QdrantVectorService(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||||
|
this.cfgHolder = cfgHolder;
|
||||||
|
}
|
||||||
/**
|
/**
|
||||||
* 插入/更新一条向量数据
|
* 插入/更新一条向量数据
|
||||||
*
|
*
|
||||||
@@ -88,19 +96,32 @@ public class QdrantVectorService {
|
|||||||
// }
|
// }
|
||||||
// }
|
// }
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 搜索向量(高并发优化版本,避免阻塞线程池)
|
||||||
|
*
|
||||||
|
* @param userVector 用户输入的向量
|
||||||
|
* @param limit 返回结果数量限制
|
||||||
|
* @return 搜索结果列表
|
||||||
|
*/
|
||||||
public List<QdrantSearchItem> searchPoint(float[] userVector, int limit) {
|
public List<QdrantSearchItem> searchPoint(float[] userVector, int limit) {
|
||||||
try {
|
try {
|
||||||
Points.QueryPoints query = Points.QueryPoints.newBuilder()
|
Points.QueryPoints query = Points.QueryPoints.newBuilder()
|
||||||
.setCollectionName(COLLECTION_NAME) // ★ 必须
|
.setCollectionName(COLLECTION_NAME) // ★ 必须
|
||||||
.setQuery(nearest(userVector)) // ★ 语义向量
|
.setQuery(nearest(userVector)) // ★ 语义向量
|
||||||
.setLimit(limit) // 限制返回数量
|
.setLimit(limit) // 限制返回数量
|
||||||
.setWithPayload(enable(true)) // ★ 带上 payload
|
.setWithPayload(enable(true)) // ★ 带上 payload
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
List<Points.BatchResult> batchResults = qdrantClient.queryBatchAsync(
|
// 使用 ListenableFuture,添加超时保护
|
||||||
|
ListenableFuture<List<Points.BatchResult>> future = qdrantClient.queryBatchAsync(
|
||||||
COLLECTION_NAME,
|
COLLECTION_NAME,
|
||||||
List.of(query)
|
List.of(query)
|
||||||
).get();
|
);
|
||||||
|
|
||||||
|
// 设置超时时间(10秒),避免无限等待
|
||||||
|
List<Points.BatchResult> batchResults = future.get(
|
||||||
|
10, java.util.concurrent.TimeUnit.SECONDS
|
||||||
|
);
|
||||||
Points.BatchResult batchResult = batchResults.get(0);
|
Points.BatchResult batchResult = batchResults.get(0);
|
||||||
|
|
||||||
// 3. 把 Protobuf 的 ScoredPoint 转成你的 DTO
|
// 3. 把 Protobuf 的 ScoredPoint 转成你的 DTO
|
||||||
@@ -129,10 +150,17 @@ public class QdrantVectorService {
|
|||||||
})
|
})
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
|
} catch (InterruptedException e) {
|
||||||
} catch (InterruptedException | ExecutionException e) {
|
// 恢复中断状态,避免吞掉中断信号
|
||||||
log.error("search point 失败", e);
|
Thread.currentThread().interrupt();
|
||||||
throw new BusinessException(ErrorCode.OPERATION_ERROR);
|
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, "向量搜索执行失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,12 +186,14 @@ public class QdrantVectorService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public List<QdrantSearchItem> searchText(String userInput) {
|
public List<QdrantSearchItem> searchText(String userInput) {
|
||||||
|
AppConfig appConfig = cfgHolder.getRef().get();
|
||||||
|
|
||||||
long t0 = System.currentTimeMillis();
|
long t0 = System.currentTimeMillis();
|
||||||
|
|
||||||
float[] floats = this.embedTextToVector(userInput);
|
float[] floats = this.embedTextToVector(userInput);
|
||||||
long t1 = System.currentTimeMillis();
|
long t1 = System.currentTimeMillis();
|
||||||
|
|
||||||
List<QdrantSearchItem> qdrantSearchItems = this.searchPoint(floats, 1);
|
List<QdrantSearchItem> qdrantSearchItems = this.searchPoint(floats, appConfig.getQdrantConfig().getVectorSearchLimit());
|
||||||
long t2 = System.currentTimeMillis();
|
long t2 = System.currentTimeMillis();
|
||||||
|
|
||||||
log.info("embedding = {} ms, qdrant = {} ms, total = {} ms",
|
log.info("embedding = {} ms, qdrant = {} ms, total = {} ms",
|
||||||
|
|||||||
@@ -5,27 +5,29 @@ import cn.hutool.core.bean.BeanUtil;
|
|||||||
import cn.hutool.core.util.IdUtil;
|
import cn.hutool.core.util.IdUtil;
|
||||||
import cn.hutool.core.util.RandomUtil;
|
import cn.hutool.core.util.RandomUtil;
|
||||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
|
|
||||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
|
||||||
import com.yolo.keyborad.common.ErrorCode;
|
import com.yolo.keyborad.common.ErrorCode;
|
||||||
|
import com.yolo.keyborad.config.AppConfig;
|
||||||
|
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||||
import com.yolo.keyborad.exception.BusinessException;
|
import com.yolo.keyborad.exception.BusinessException;
|
||||||
import com.yolo.keyborad.mapper.KeyboardUserMapper;
|
import com.yolo.keyborad.mapper.KeyboardUserMapper;
|
||||||
import com.yolo.keyborad.model.dto.user.*;
|
import com.yolo.keyborad.model.dto.user.*;
|
||||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal;
|
||||||
|
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
|
||||||
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
import com.yolo.keyborad.model.vo.user.KeyboardUserRespVO;
|
||||||
import com.yolo.keyborad.service.KeyboardCharacterService;
|
import com.yolo.keyborad.service.*;
|
||||||
import com.yolo.keyborad.service.UserService;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import com.yolo.keyborad.utils.RedisUtil;
|
import com.yolo.keyborad.utils.RedisUtil;
|
||||||
import com.yolo.keyborad.utils.SendMailUtils;
|
import com.yolo.keyborad.utils.SendMailUtils;
|
||||||
import jakarta.annotation.Resource;
|
import jakarta.annotation.Resource;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
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.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.util.Date;
|
||||||
import java.util.concurrent.TimeUnit;
|
import java.util.concurrent.TimeUnit;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -51,6 +53,21 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
@Resource
|
@Resource
|
||||||
private KeyboardCharacterService keyboardCharacterService;
|
private KeyboardCharacterService keyboardCharacterService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserWalletService walletService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserLoginLogService loginLogService;
|
||||||
|
|
||||||
|
@Resource
|
||||||
|
private KeyboardUserQuotaTotalService quotaTotalService;
|
||||||
|
|
||||||
|
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||||
|
|
||||||
|
public UserServiceImpl(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||||
|
this.cfgHolder = cfgHolder;
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public KeyboardUser selectUserWithSubjectId(String sub) {
|
public KeyboardUser selectUserWithSubjectId(String sub) {
|
||||||
return keyboardUserMapper.selectOne(
|
return keyboardUserMapper.selectOne(
|
||||||
@@ -67,11 +84,37 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
keyboardUser.setNickName("User_" + RandomUtil.randomString(6));
|
||||||
keyboardUserMapper.insert(keyboardUser);
|
keyboardUserMapper.insert(keyboardUser);
|
||||||
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
||||||
|
|
||||||
|
AppConfig appConfig = cfgHolder.getRef().get();
|
||||||
|
|
||||||
|
// 初始化用户钱包(余额为0)
|
||||||
|
KeyboardUserWallet wallet = new KeyboardUserWallet();
|
||||||
|
wallet.setUserId(keyboardUser.getId());
|
||||||
|
wallet.setBalance(BigDecimal.valueOf(appConfig.getUserRegisterProperties().getFreeTrialQuota()));
|
||||||
|
wallet.setVersion(0);
|
||||||
|
wallet.setStatus((short) 1);
|
||||||
|
wallet.setCreatedAt(new Date());
|
||||||
|
wallet.setUpdatedAt(new Date());
|
||||||
|
walletService.save(wallet);
|
||||||
|
|
||||||
|
// 初始化用户免费使用次数配额
|
||||||
|
KeyboardUserQuotaTotal quotaTotal = new KeyboardUserQuotaTotal();
|
||||||
|
quotaTotal.setUserId(keyboardUser.getId());
|
||||||
|
quotaTotal.setTotalQuota(appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
||||||
|
quotaTotal.setUsedQuota(0);
|
||||||
|
quotaTotal.setVersion(0);
|
||||||
|
quotaTotal.setCreatedAt(new Date());
|
||||||
|
quotaTotal.setUpdatedAt(new Date());
|
||||||
|
quotaTotalService.save(quotaTotal);
|
||||||
|
|
||||||
|
log.info("User registered with Apple Sign-In, userId={}, freeQuota={}",
|
||||||
|
keyboardUser.getId(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
||||||
|
|
||||||
return keyboardUser;
|
return keyboardUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public KeyboardUserRespVO login(UserLoginDTO userLoginDTO) {
|
public KeyboardUserRespVO login(UserLoginDTO userLoginDTO, HttpServletRequest request) {
|
||||||
KeyboardUser keyboardUser = keyboardUserMapper.selectOne(
|
KeyboardUser keyboardUser = keyboardUserMapper.selectOne(
|
||||||
new LambdaQueryWrapper<KeyboardUser>()
|
new LambdaQueryWrapper<KeyboardUser>()
|
||||||
.eq(KeyboardUser::getEmail, userLoginDTO.getMail())
|
.eq(KeyboardUser::getEmail, userLoginDTO.getMail())
|
||||||
@@ -83,6 +126,46 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
|
throw new BusinessException(ErrorCode.PASSWORD_OR_MAIL_ERROR);
|
||||||
}
|
}
|
||||||
StpUtil.login(keyboardUser.getId());
|
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 keyboardUserRespVO = BeanUtil.copyProperties(keyboardUser, KeyboardUserRespVO.class);
|
||||||
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
|
keyboardUserRespVO.setToken(StpUtil.getTokenValue());
|
||||||
return keyboardUserRespVO;
|
return keyboardUserRespVO;
|
||||||
@@ -135,11 +218,36 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
|
|||||||
if (!userRegisterDTO.getVerifyCode().equals(s)) {
|
if (!userRegisterDTO.getVerifyCode().equals(s)) {
|
||||||
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
|
throw new BusinessException(ErrorCode.VERIFY_CODE_ERROR);
|
||||||
}
|
}
|
||||||
|
AppConfig appConfig = cfgHolder.getRef().get();
|
||||||
keyboardUser.setEmailVerified(true);
|
keyboardUser.setEmailVerified(true);
|
||||||
redisUtil.delete("user:" + userRegisterDTO.getMailAddress());
|
redisUtil.delete("user:" + userRegisterDTO.getMailAddress());
|
||||||
int insertCount = keyboardUserMapper.insert(keyboardUser);
|
int insertCount = keyboardUserMapper.insert(keyboardUser);
|
||||||
if (insertCount > 0) {
|
if (insertCount > 0) {
|
||||||
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
|
||||||
|
|
||||||
|
// 初始化用户钱包(余额为0)
|
||||||
|
KeyboardUserWallet wallet = new KeyboardUserWallet();
|
||||||
|
wallet.setUserId(keyboardUser.getId());
|
||||||
|
wallet.setBalance(appConfig.getUserRegisterProperties().getRewardBalance());
|
||||||
|
wallet.setVersion(0);
|
||||||
|
wallet.setStatus((short) 1);
|
||||||
|
wallet.setCreatedAt(new Date());
|
||||||
|
wallet.setUpdatedAt(new Date());
|
||||||
|
walletService.save(wallet);
|
||||||
|
|
||||||
|
// 初始化用户免费使用次数配额
|
||||||
|
com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal quotaTotal =
|
||||||
|
new com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal();
|
||||||
|
quotaTotal.setUserId(keyboardUser.getId());
|
||||||
|
quotaTotal.setTotalQuota(appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
||||||
|
quotaTotal.setUsedQuota(0);
|
||||||
|
quotaTotal.setVersion(0);
|
||||||
|
quotaTotal.setCreatedAt(new Date());
|
||||||
|
quotaTotal.setUpdatedAt(new Date());
|
||||||
|
quotaTotalService.save(quotaTotal);
|
||||||
|
|
||||||
|
log.info("User registered with email, userId={}, email={}, freeQuota={}",
|
||||||
|
keyboardUser.getId(), keyboardUser.getEmail(), appConfig.getUserRegisterProperties().getFreeTrialQuota());
|
||||||
}
|
}
|
||||||
return insertCount > 0;
|
return insertCount > 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
81
src/main/java/com/yolo/keyborad/utils/JwtParser.java
Normal file
81
src/main/java/com/yolo/keyborad/utils/JwtParser.java
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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);
|
||||||
|
|
||||||
|
// 调试信息
|
||||||
|
System.out.println("Cert Base64 length: " + certBase64.length());
|
||||||
|
System.out.println("First 50 chars: " + certBase64.substring(0, Math.min(50, certBase64.length())));
|
||||||
|
|
||||||
|
// x5c 中的证书使用标准 Base64 编码(非 URL 安全编码)
|
||||||
|
byte[] certBytes;
|
||||||
|
try {
|
||||||
|
certBytes = Base64.getDecoder().decode(certBase64); // 使用标准 Base64 解码
|
||||||
|
System.out.println("Decoded cert bytes length: " + certBytes.length);
|
||||||
|
} catch (IllegalArgumentException e) {
|
||||||
|
System.err.println("Base64 decode error: " + e.getMessage());
|
||||||
|
throw new Exception("Failed to decode certificate from x5c", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 X509 证书并提取公钥
|
||||||
|
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
|
||||||
|
X509Certificate cert = (X509Certificate) certFactory.generateCertificate(
|
||||||
|
new ByteArrayInputStream(certBytes)
|
||||||
|
);
|
||||||
|
return cert.getPublicKey();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,27 +5,27 @@ spring:
|
|||||||
username: root
|
username: root
|
||||||
password: 123asd
|
password: 123asd
|
||||||
|
|
||||||
# 日志配置
|
# ????
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
# 设置 mapper 包的日志级别为 DEBUG,打印 SQL 语句
|
# ?? mapper ??????? DEBUG??? SQL ??
|
||||||
com.yolo.keyborad.mapper: DEBUG
|
com.yolo.keyborad.mapper: DEBUG
|
||||||
# 设置根日志级别
|
# ???????
|
||||||
root: INFO
|
root: INFO
|
||||||
# Spring 框架日志
|
# Spring ????
|
||||||
org.springframework: INFO
|
org.springframework: INFO
|
||||||
# MyBatis 日志
|
# MyBatis ??
|
||||||
org.mybatis: DEBUG
|
org.mybatis: DEBUG
|
||||||
pattern:
|
pattern:
|
||||||
# 彩色控制台日志格式
|
# ?????????
|
||||||
# 时间-无颜色,日志级别-根据级别变色,进程ID-品红,线程-黄色,类名-青色,消息-默认色
|
# ??-????????-?????????ID-?????-?????-?????-???
|
||||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %clr(%-5level){highlight} %clr(${PID:- }){magenta} | %clr(%-15thread){yellow} %clr(%-50logger{50}){cyan} | %msg%n"
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %clr(%-5level){highlight} %clr(${PID:- }){magenta} | %clr(%-15thread){yellow} %clr(%-50logger{50}){cyan} | %msg%n"
|
||||||
|
|
||||||
|
|
||||||
knife4j:
|
knife4j:
|
||||||
enable: true
|
enable: true
|
||||||
openapi:
|
openapi:
|
||||||
title: "接口文档"
|
title: "????"
|
||||||
version: 1.0
|
version: 1.0
|
||||||
group:
|
group:
|
||||||
default:
|
default:
|
||||||
@@ -38,59 +38,56 @@ apple:
|
|||||||
issuer-id: "178b442e-b7be-4526-bd13-ab293d019df0"
|
issuer-id: "178b442e-b7be-4526-bd13-ab293d019df0"
|
||||||
key-id: "Y7TF7BV74G"
|
key-id: "Y7TF7BV74G"
|
||||||
bundle-id: "com.loveKey.nyx"
|
bundle-id: "com.loveKey.nyx"
|
||||||
# app 在 App Store 的 Apple ID(数值),生产环境必填
|
# app ? App Store ? Apple ID???????????
|
||||||
app-apple-id: 1234567890
|
app-apple-id: 1234567890
|
||||||
|
|
||||||
# p8 私钥文件路径(你可以放在 resources 下)
|
# p8 ???????????? resources ??
|
||||||
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
private-key-path: "classpath:SubscriptionKey_Y7TF7BV74G.p8"
|
||||||
|
|
||||||
# SANDBOX 或 PRODUCTION
|
# SANDBOX ? PRODUCTION
|
||||||
environment: "SANDBOX"
|
environment: "SANDBOX"
|
||||||
|
|
||||||
# 根证书路径(从 Apple PKI 下载)
|
# ??????? Apple PKI ???
|
||||||
root-certificates:
|
root-certificates:
|
||||||
- "classpath:AppleRootCA-G2.cer"
|
- "classpath:AppleRootCA-G2.cer"
|
||||||
- "classpath:AppleRootCA-G3.cer"
|
- "classpath:AppleRootCA-G3.cer"
|
||||||
|
|
||||||
dromara:
|
dromara:
|
||||||
x-file-storage: #文件存储配置
|
x-file-storage: #??????
|
||||||
default-platform: cloudflare-r2 #默认使用的存储平台
|
default-platform: cloudflare-r2 #?????????
|
||||||
thumbnail-suffix: ".min.jpg" #缩略图后缀,例如【.min.jpg】【.png】
|
thumbnail-suffix: ".min.jpg" #?????????.min.jpg??.png?
|
||||||
enable-byte-file-wrapper: false
|
enable-byte-file-wrapper: false
|
||||||
#对应平台的配置写在这里,注意缩进要对齐
|
#???????????????????
|
||||||
amazon-s3-v2: # Amazon S3 V2
|
amazon-s3-v2: # Amazon S3 V2
|
||||||
- platform: cloudflare-r2 # 存储平台标识
|
- platform: cloudflare-r2 # ??????
|
||||||
enable-storage: true # 启用存储
|
enable-storage: true # ????
|
||||||
access-key: 550b33cc4d53e05c2e438601f8a0e209
|
access-key: 550b33cc4d53e05c2e438601f8a0e209
|
||||||
secret-key: df4d529cdae44e6f614ca04f4dc0f1f9a299e57367181243e8abdc7f7c28e99a
|
secret-key: df4d529cdae44e6f614ca04f4dc0f1f9a299e57367181243e8abdc7f7c28e99a
|
||||||
region: ENAM # 必填
|
region: ENAM # ??
|
||||||
end-point: https://b632a61caa85401f63c9b32eef3a74c8.r2.cloudflarestorage.com # 必填
|
end-point: https://b632a61caa85401f63c9b32eef3a74c8.r2.cloudflarestorage.com # ??
|
||||||
bucket-name: keyborad-resource #桶名称
|
bucket-name: keyborad-resource #???
|
||||||
domain: https://resource.loveamorkey.com/ # 访问域名,注意“/”结尾,例如:https://abcd.s3.ap-east-1.amazonaws.com/
|
domain: https://resource.loveamorkey.com/ # ????????/???????https://abcd.s3.ap-east-1.amazonaws.com/
|
||||||
base-path: avatar/ # 基础路径
|
base-path: avatar/ # ????
|
||||||
|
|
||||||
|
############## Sa-Token ?? (??: https://sa-token.cc) ##############
|
||||||
mailgun:
|
|
||||||
api-key: ${MAILGUN_API_KEY} # 你的 Private API Key
|
|
||||||
domain: sandboxxxxxxx.mailgun.org # 或你自己的业务域名
|
|
||||||
from-email: no-reply@yourdomain.com # 发件人邮箱
|
|
||||||
from-name: Key Of Love # 发件人名称(可选)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
############## Sa-Token 配置 (文档: https://sa-token.cc) ##############
|
|
||||||
sa-token:
|
sa-token:
|
||||||
# token 名称(同时也是 cookie 名称)
|
# token ??????? cookie ???
|
||||||
token-name: auth-token
|
token-name: auth-token
|
||||||
# token 有效期(单位:秒) 默认30天,-1 代表永久有效
|
# token ????????? ??30??-1 ??????
|
||||||
timeout: 2592000
|
timeout: 2592000
|
||||||
# token 最低活跃频率(单位:秒),如果 token 超过此时间没有访问系统就会被冻结,默认-1 代表不限制,永不冻结
|
# token ??????????????? token ???????????????????-1 ??????????
|
||||||
active-timeout: -1
|
active-timeout: -1
|
||||||
# 是否允许同一账号多地同时登录 (为 true 时允许一起登录, 为 false 时新登录挤掉旧登录)
|
# ?????????????? ?? true ???????, ? false ??????????
|
||||||
is-concurrent: true
|
is-concurrent: true
|
||||||
# 在多人登录同一账号时,是否共用一个 token (为 true 时所有登录共用一个 token, 为 false 时每次登录新建一个 token)
|
# ????????????????? token ?? true ????????? token, ? false ????????? token?
|
||||||
is-share: false
|
is-share: false
|
||||||
# token 风格(默认可取值:uuid、simple-uuid、random-32、random-64、random-128、tik)
|
# token ?????????uuid?simple-uuid?random-32?random-64?random-128?tik?
|
||||||
token-style: random-128
|
token-style: random-128
|
||||||
# 是否输出操作日志
|
# ????????
|
||||||
is-log: true
|
is-log: true
|
||||||
|
|
||||||
|
nacos:
|
||||||
|
config:
|
||||||
|
server-addr: 127.0.0.1:8848
|
||||||
|
group: DEFAULT_GROUP
|
||||||
|
data-id: keyboard_default-config.yaml
|
||||||
@@ -5,26 +5,34 @@ spring:
|
|||||||
username: root
|
username: root
|
||||||
password: 123asd
|
password: 123asd
|
||||||
|
|
||||||
# 生产环境日志配置
|
# ????????
|
||||||
logging:
|
logging:
|
||||||
level:
|
level:
|
||||||
# 生产环境不打印 SQL 日志
|
# ??????? SQL ??
|
||||||
com.yolo.keyborad.mapper: INFO
|
com.yolo.keyborad.mapper: INFO
|
||||||
# 设置根日志级别
|
# ???????
|
||||||
root: INFO
|
root: INFO
|
||||||
# Spring 框架日志
|
# Spring ????
|
||||||
org.springframework: WARN
|
org.springframework: WARN
|
||||||
# MyBatis 日志
|
# MyBatis ??
|
||||||
org.mybatis: WARN
|
org.mybatis: WARN
|
||||||
pattern:
|
pattern:
|
||||||
# 生产环境控制台日志格式
|
# ???????????
|
||||||
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %clr(%-5level){highlight} %clr(${PID:- }){magenta} | %clr(%-15thread){yellow} %clr(%-50logger{50}){cyan} | %msg%n"
|
console: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %clr(%-5level){highlight} %clr(${PID:- }){magenta} | %clr(%-15thread){yellow} %clr(%-50logger{50}){cyan} | %msg%n"
|
||||||
# 文件日志格式(无颜色代码)
|
# ?????????????
|
||||||
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level ${PID:- } | %-15thread %-50logger{50} | %msg%n"
|
file: "%d{yyyy-MM-dd HH:mm:ss.SSS} | %-5level ${PID:- } | %-15thread %-50logger{50} | %msg%n"
|
||||||
file:
|
file:
|
||||||
# 生产环境日志文件路径
|
# ??????????
|
||||||
name: logs/keyborad-backend.log
|
name: logs/keyborad-backend.log
|
||||||
# 日志文件大小限制
|
# ????????
|
||||||
max-size: 10MB
|
# ?????????
|
||||||
# 保留的日志文件数量
|
logback:
|
||||||
max-history: 30
|
rollingpolicy:
|
||||||
|
max-file-size: 10MB
|
||||||
|
max-history: 30
|
||||||
|
|
||||||
|
# ??????
|
||||||
|
user:
|
||||||
|
register:
|
||||||
|
# ?????????????
|
||||||
|
free-trial-quota: 5
|
||||||
|
|||||||
@@ -18,17 +18,17 @@ spring:
|
|||||||
mvc:
|
mvc:
|
||||||
pathmatch:
|
pathmatch:
|
||||||
matching-strategy: ANT_PATH_MATCHER
|
matching-strategy: ANT_PATH_MATCHER
|
||||||
# session 失效时间(分钟)
|
# session ????????
|
||||||
session:
|
session:
|
||||||
timeout: 86400
|
timeout: 86400
|
||||||
store-type: redis
|
store-type: redis
|
||||||
# redis 配置
|
# redis ??
|
||||||
data:
|
data:
|
||||||
redis:
|
redis:
|
||||||
port: 6379
|
port: 6379
|
||||||
host: localhost
|
host: localhost
|
||||||
database: 0
|
database: 0
|
||||||
# 启用 ANSI 彩色输出
|
# ?? ANSI ????
|
||||||
output:
|
output:
|
||||||
ansi:
|
ansi:
|
||||||
enabled: always
|
enabled: always
|
||||||
@@ -47,10 +47,10 @@ mybatis-plus:
|
|||||||
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
|
||||||
global-config:
|
global-config:
|
||||||
db-config:
|
db-config:
|
||||||
logic-delete-field: isDelete # 全局逻辑删除的实体字段名(since 3.3.0,配置后可以忽略不配置步骤2)
|
logic-delete-field: isDelete # ????????????(since 3.3.0,????????????2)
|
||||||
logic-delete-value: 1 # 逻辑已删除值(默认为 1)
|
logic-delete-value: 1 # ??????(??? 1)
|
||||||
logic-not-delete-value: 0 # 逻辑未删除值(默认为 0)
|
logic-not-delete-value: 0 # ??????(??? 0)
|
||||||
# 扫描 TypeHandler 包
|
# ?? TypeHandler ?
|
||||||
type-handlers-package: com.yolo.keyborad.typehandler
|
type-handlers-package: com.yolo.keyborad.typehandler
|
||||||
|
|
||||||
appid: loveKeyboard
|
appid: loveKeyboard
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<mapper namespace="com.yolo.keyborad.mapper.I18nMessageMapper">
|
<mapper namespace="com.yolo.keyborad.mapper.I18nMessageMapper">
|
||||||
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.I18nMessage">
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.I18nMessage">
|
||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
<!--@Table i18n_message-->
|
<!--@Table keyboard_i18n_message-->
|
||||||
<id column="id" jdbcType="BIGINT" property="id" />
|
<id column="id" jdbcType="BIGINT" property="id" />
|
||||||
<result column="code" jdbcType="VARCHAR" property="code" />
|
<result column="code" jdbcType="VARCHAR" property="code" />
|
||||||
<result column="locale" jdbcType="VARCHAR" property="locale" />
|
<result column="locale" jdbcType="VARCHAR" property="locale" />
|
||||||
@@ -15,6 +15,6 @@
|
|||||||
</sql>
|
</sql>
|
||||||
|
|
||||||
<select id="selectByCodeAndLocale" resultMap="BaseResultMap">
|
<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>
|
</select>
|
||||||
</mapper>
|
</mapper>
|
||||||
15
src/main/resources/mapper/KeyboardFeedbackMapper.xml
Normal file
15
src/main/resources/mapper/KeyboardFeedbackMapper.xml
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<?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.KeyboardFeedbackMapper">
|
||||||
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardFeedback">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
<!--@Table keyboard_feedback-->
|
||||||
|
<id column="id" jdbcType="BIGINT" property="id" />
|
||||||
|
<result column="content" jdbcType="VARCHAR" property="content" />
|
||||||
|
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
|
||||||
|
</resultMap>
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
id, content, created_at
|
||||||
|
</sql>
|
||||||
|
</mapper>
|
||||||
26
src/main/resources/mapper/KeyboardProductItemsMapper.xml
Normal file
26
src/main/resources/mapper/KeyboardProductItemsMapper.xml
Normal 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>
|
||||||
25
src/main/resources/mapper/KeyboardUserCallLogMapper.xml
Normal file
25
src/main/resources/mapper/KeyboardUserCallLogMapper.xml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
<?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.KeyboardUserCallLogMapper">
|
||||||
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserCallLog">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
<!--@Table keyboard_user_call_log-->
|
||||||
|
<id column="id" jdbcType="BIGINT" property="id" />
|
||||||
|
<result column="user_id" jdbcType="BIGINT" property="userId" />
|
||||||
|
<result column="request_id" jdbcType="VARCHAR" property="requestId" />
|
||||||
|
<result column="feature" jdbcType="VARCHAR" property="feature" />
|
||||||
|
<result column="model" jdbcType="VARCHAR" property="model" />
|
||||||
|
<result column="input_tokens" jdbcType="INTEGER" property="inputTokens" />
|
||||||
|
<result column="output_tokens" jdbcType="INTEGER" property="outputTokens" />
|
||||||
|
<result column="total_tokens" jdbcType="INTEGER" property="totalTokens" />
|
||||||
|
<result column="success" jdbcType="BOOLEAN" property="success" />
|
||||||
|
<result column="latency_ms" jdbcType="INTEGER" property="latencyMs" />
|
||||||
|
<result column="error_code" jdbcType="VARCHAR" property="errorCode" />
|
||||||
|
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
|
||||||
|
</resultMap>
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
id, user_id, request_id, feature, model, input_tokens, output_tokens, total_tokens,
|
||||||
|
success, latency_ms, error_code, created_at
|
||||||
|
</sql>
|
||||||
|
</mapper>
|
||||||
23
src/main/resources/mapper/KeyboardUserLoginLogMapper.xml
Normal file
23
src/main/resources/mapper/KeyboardUserLoginLogMapper.xml
Normal 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>
|
||||||
@@ -17,11 +17,13 @@
|
|||||||
<result column="password" jdbcType="VARCHAR" property="password" />
|
<result column="password" jdbcType="VARCHAR" property="password" />
|
||||||
<result column="subject_id" jdbcType="VARCHAR" property="subjectId" />
|
<result column="subject_id" jdbcType="VARCHAR" property="subjectId" />
|
||||||
<result column="email_verified" jdbcType="BOOLEAN" property="emailVerified" />
|
<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>
|
</resultMap>
|
||||||
<sql id="Base_Column_List">
|
<sql id="Base_Column_List">
|
||||||
<!--@mbg.generated-->
|
<!--@mbg.generated-->
|
||||||
id, "uid", nick_name, gender, avatar_url, created_at, updated_at, deleted, email,
|
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>
|
</sql>
|
||||||
|
|
||||||
<update id="updateByuid">
|
<update id="updateByuid">
|
||||||
|
|||||||
@@ -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>
|
||||||
18
src/main/resources/mapper/KeyboardUserQuotaTotalMapper.xml
Normal file
18
src/main/resources/mapper/KeyboardUserQuotaTotalMapper.xml
Normal 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.KeyboardUserQuotaTotalMapper">
|
||||||
|
<resultMap id="BaseResultMap" type="com.yolo.keyborad.model.entity.KeyboardUserQuotaTotal">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
<!--@Table keyboard_user_quota_total-->
|
||||||
|
<id column="user_id" jdbcType="BIGINT" property="userId" />
|
||||||
|
<result column="total_quota" jdbcType="INTEGER" property="totalQuota" />
|
||||||
|
<result column="used_quota" jdbcType="INTEGER" property="usedQuota" />
|
||||||
|
<result column="version" jdbcType="INTEGER" property="version" />
|
||||||
|
<result column="created_at" jdbcType="TIMESTAMP" property="createdAt" />
|
||||||
|
<result column="updated_at" jdbcType="TIMESTAMP" property="updatedAt" />
|
||||||
|
</resultMap>
|
||||||
|
<sql id="Base_Column_List">
|
||||||
|
<!--@mbg.generated-->
|
||||||
|
user_id, total_quota, used_quota, version, created_at, updated_at
|
||||||
|
</sql>
|
||||||
|
</mapper>
|
||||||
88
src/test/java/com/yolo/keyborad/AppStoreJWT.java
Normal file
88
src/test/java/com/yolo/keyborad/AppStoreJWT.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
src/test/java/com/yolo/keyborad/JwtParser.java
Normal file
88
src/test/java/com/yolo/keyborad/JwtParser.java
Normal file
File diff suppressed because one or more lines are too long
35
src/test/java/com/yolo/keyborad/SendAttemptsParser.java
Normal file
35
src/test/java/com/yolo/keyborad/SendAttemptsParser.java
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/test/java/com/yolo/keyborad/test.java
Normal file
32
src/test/java/com/yolo/keyborad/test.java
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user