Compare commits

..

19 Commits

Author SHA1 Message Date
95fb77a575 fix(chat): 保存LLM响应的生成ID用于链路追踪 2025-12-17 19:23:21 +08:00
abfac871fd feat(user): 新增用户反馈提交功能 2025-12-17 18:20:05 +08:00
198650556f feat(themes): 新增主题模糊搜索接口及鉴权放行
支持按名称模糊搜索主题,并标记用户已购状态;同步放开 /themes/search 无需登录访问
2025-12-17 16:57:39 +08:00
4666180b73 style(service): 添加详细注释并优化人设列表查询逻辑 2025-12-17 16:49:10 +08:00
35c45abf73 feat(service): 为标签人设列表增加Redis缓存 2025-12-17 16:46:50 +08:00
65cd9d9fae feat(service): 为所有人设列表添加Redis缓存
在 selectListWithRank 中先读缓存,未命中再查库并写入7天过期缓存,减少数据库压力。
2025-12-17 16:32:05 +08:00
0156156440 feat(character): 添加Redis缓存支持人设查询 2025-12-17 16:25:05 +08:00
27a8911b7f refactor(service): 优化聊天服务代码注释与结构
添加详细中文注释,明确用户配额校验、VIP权限判断及流式响应处理逻辑,提升可维护性。
2025-12-17 16:05:14 +08:00
323baa876f feat(chat): 新增免费额度与VIP校验逻辑
在聊天接口中增加用户免费次数及VIP身份校验,未通过时返回新错误码50022;
调用成功后若使用免费额度则自动扣减,保障额度体系闭环。
2025-12-17 15:51:46 +08:00
2621321dea refactor(chat): 拆分聊天逻辑至独立 ChatService 并提取 LLM 配置
将 ChatController 中的聊天与向量搜索流程整体迁移到 ChatServiceImpl,
新增 AppConfig.LLmConfig 集中管理系统提示语与最大消息长度,
消除控制器层复杂逻辑,提升可维护性与配置动态化能力。
2025-12-17 15:36:57 +08:00
86738e3d1b feat(chat): 新增聊天调用日志与动态配置支持
- 新增 KeyboardUserCallLog 实体及对应 Mapper、Service,用于记录每次聊天请求的模型、token、耗时、错误码等
- ChatController.talk() 在流式输出前后采集元数据,异步落库,支持错误码记录
- AppConfig 新增 QdrantConfig,支持 vectorSearchLimit 动态配置
- QdrantVectorService 改为从 Nacos 动态读取搜索条数,替代硬编码 limit=1
- UserController 登出时先清除用户会话再清除 token,避免并发异常
2025-12-17 15:03:23 +08:00
a237bc2987 refactor(config): 合并用户注册配置并调整默认配额
将 UserRegisterProperties 内嵌到 AppConfig,删除独立配置类;
freeTrialQuota 由 5 改为 3,新增 rewardBalance 字段;
同步更新 UserServiceImpl 初始化逻辑及 yml 配置。
2025-12-17 13:24:38 +08:00
8e26488738 feat(config): 接入 Nacos 配置中心
- 新增 AppConfig、NacosAppConfigCenter 动态配置类
- 将 userRegisterProperties 的默认值改为运行时从 Nacos 读取
- 注册/创建用户时免费配额改为动态配置获取
- 增加 nacos-client 依赖并配置 dev 环境连接信息
2025-12-16 21:50:00 +08:00
f95762138b feat(quota): 新增用户额度总计模块
增加用户免费体验额度配置,支持新用户注册时的额度分配功能
2025-12-16 17:54:53 +08:00
495485cc07 feat(quota): 新增用户额度总计模块
增加用户免费体验额度配置,支持新用户注册时的额度分配功能
2025-12-16 16:59:56 +08:00
cd6eca9cbb feat(apple): 支持App Store Server V2通知全类型处理
- 新增订阅、退款、偏好变更、消费请求等通知处理器
- 统一使用ResponseBodyV2DecodedPayload验签与分发
- 移除控制器层JWT解析逻辑,下沉至服务层
- 增加幂等、状态回滚及权益撤销/恢复能力
2025-12-16 15:50:35 +08:00
c54c14de58 refactor(service): 改用JWS验签,移除旧收据解析
废弃ReceiptUtility与AppStoreServerAPIClient,直接以SignedDataVerifier校验客户端传来的signedPayload(JWS),简化流程并减少一次网络IO。
2025-12-16 15:06:41 +08:00
c305dfaae4 fix(apple): 增加无效收据原因日志并补充订阅过期时间调试输出 2025-12-15 21:21:47 +08:00
0ad9de1011 fix(controller): 使用官方 SDK 验证 Apple 交易签名并解析 payload 2025-12-15 18:22:11 +08:00
43 changed files with 1927 additions and 472 deletions

View File

@@ -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>

View File

@@ -56,7 +56,8 @@ public enum ErrorCode {
PRODUCT_QUOTA_NOT_SET(50018, "商品额度未配置"), PRODUCT_QUOTA_NOT_SET(50018, "商品额度未配置"),
LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"), LACK_ORIGIN_TRANSACTION_ID_ERROR(50019, "缺少原始交易id"),
UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"), UNKNOWN_PRODUCT_TYPE(50020, "未知商品类型"),
PRODUCT_NOT_FOUND(50021, "商品不存在"); PRODUCT_NOT_FOUND(50021, "商品不存在"),
NO_QUOTA_AND_NOT_VIP(50022, "免费次数已用完请开通VIP");
/** /**
* 状态码 * 状态码

View 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;
}
}

View File

@@ -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();
} }

View File

@@ -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());
}
}

View File

@@ -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;
}
} }

View File

@@ -92,6 +92,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
"/themes/purchase/list", "/themes/purchase/list",
"/themes/detail", "/themes/detail",
"/themes/recommended", "/themes/recommended",
"/themes/search",
"/user-themes/batch-delete", "/user-themes/batch-delete",
"/products/listByType", "/products/listByType",
"/products/detail", "/products/detail",

View File

@@ -6,11 +6,8 @@ import com.yolo.keyborad.common.ErrorCode;
import com.yolo.keyborad.common.ResultUtils; import com.yolo.keyborad.common.ResultUtils;
import com.yolo.keyborad.exception.BusinessException; import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult; import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
import com.yolo.keyborad.model.dto.AppleServerNotification;
import com.yolo.keyborad.service.ApplePurchaseService; import com.yolo.keyborad.service.ApplePurchaseService;
import com.yolo.keyborad.service.AppleReceiptService; import com.yolo.keyborad.service.AppleReceiptService;
import jakarta.servlet.http.HttpServletRequest;
import com.yolo.keyborad.utils.JwtParser;
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;
@@ -18,8 +15,6 @@ import org.springframework.web.bind.annotation.RestController;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import java.util.Map; import java.util.Map;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
@RestController @RestController
@RequestMapping("/apple") @RequestMapping("/apple")
@@ -37,18 +32,27 @@ public class AppleReceiptController {
@PostMapping("/receipt") @PostMapping("/receipt")
public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) { public AppleReceiptValidationResult validateReceipt(@RequestBody Map<String, String> body) {
if (body == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
String receipt = body.get("receipt"); String receipt = body.get("receipt");
if (receipt == null || receipt.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空");
}
return appleReceiptService.validateReceipt(receipt); return appleReceiptService.validateReceipt(receipt);
} }
@PostMapping("/validate-receipt") @PostMapping("/validate-receipt")
public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) { public BaseResponse<Boolean> handlePurchase(@RequestBody Map<String, String> body) {
String receipt = body.get("receipt"); if (body == null) {
if (receipt == null || receipt.isBlank()) { throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
throw new BusinessException(ErrorCode.PARAMS_ERROR, "receipt 不能为空"); }
String signedPayload = body.get("signedPayload");
if (signedPayload == null || signedPayload.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
} }
Long userId = StpUtil.getLoginIdAsLong(); Long userId = StpUtil.getLoginIdAsLong();
AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(receipt); AppleReceiptValidationResult validationResult = appleReceiptService.validateReceipt(signedPayload);
applePurchaseService.processPurchase(userId, validationResult); applePurchaseService.processPurchase(userId, validationResult);
return ResultUtils.success(Boolean.TRUE); return ResultUtils.success(Boolean.TRUE);
} }
@@ -56,144 +60,29 @@ public class AppleReceiptController {
/** /**
* 接收 Apple 服务器通知 * 接收 Apple 服务器通知
* 处理来自 Apple 的服务器到服务器通知,主要用于订阅续订等事件 * 处理来自 Apple 的服务器到服务器通知,包括订阅续订、退款等事件
* 所有验证和处理逻辑都委托给 service 层
* *
* @param body 请求体,包含 signedPayload 字段 * @param body 请求体,包含 signedPayload 字段
* @param request HTTP 请求对象
* @return 处理结果 * @return 处理结果
* @throws BusinessException 当 signedPayload 为空或解析失败时抛出 * @throws BusinessException 当 signedPayload 为空时抛出
*/ */
@PostMapping("/notification") @PostMapping("/notification")
public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body, HttpServletRequest request) { public BaseResponse<Boolean> receiveNotification(@RequestBody Map<String, String> body) {
// 从请求体中获取 Apple 签名的载荷 // 参数校验
String signedPayload = body.get("signedPayload"); if (body == null) {
log.warn(body.toString()); throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
}
// 校验 signedPayload 是否为空 String signedPayload = body.get("signedPayload");
if (signedPayload == null || signedPayload.isBlank()) { if (signedPayload == null || signedPayload.isBlank()) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空"); throw new BusinessException(ErrorCode.PARAMS_ERROR, "signedPayload 不能为空");
} }
// 解码签名载荷,获取通知详情 // 委托给 service 层处理所有通知逻辑
AppleServerNotification notification = decodeSignedPayload(signedPayload); appleReceiptService.processNotification(signedPayload);
log.info("Apple server notification decoded: {}, query: {}", notification, request.getQueryString());
// 判断是否为续订相关通知,如果是则进行处理
if (notification != null && notification.getNotificationType() != null
&& notification.getNotificationType().toUpperCase().contains("RENEW")) {
applePurchaseService.processRenewNotification(notification);
}
return ResultUtils.success(Boolean.TRUE); return ResultUtils.success(Boolean.TRUE);
} }
/**
* 解码 Apple 签名的 JWT 载荷
* 从 signedPayload 中解析出服务器通知的详细信息包括通知类型、环境、产品ID、交易信息等
*
* @param signedPayload Apple 服务器发送的签名载荷JWT 格式)
* @return AppleServerNotification 对象,包含解析后的通知详情
* @throws BusinessException 当参数无效或解析失败时抛出
*/
private AppleServerNotification decodeSignedPayload(String signedPayload) {
try {
// 解析外层 JWT 载荷
JsonNode root = JwtParser.parsePayload(signedPayload);
AppleServerNotification notification = new AppleServerNotification();
// 获取通知类型(支持驼峰和下划线两种命名格式)
notification.setNotificationType(text(root, "notificationType", "notification_type"));
// 解析 data 节点中的基本信息
JsonNode data = root.get("data");
if (data != null && !data.isNull()) {
notification.setEnvironment(text(data, "environment"));
notification.setProductId(text(data, "productId", "product_id"));
notification.setOriginalTransactionId(text(data, "originalTransactionId", "original_transaction_id"));
notification.setSignedTransactionInfo(text(data, "signedTransactionInfo", "signed_transaction_info"));
}
// 如果存在签名的交易信息,进一步解析嵌套的 JWT
if (notification.getSignedTransactionInfo() != null) {
JsonNode txNode = JwtParser.parsePayload(notification.getSignedTransactionInfo());
// 从交易信息中提取详细字段
notification.setTransactionId(text(txNode, "transactionId", "transaction_id"));
// 优先使用外层的值,如果为空则使用交易信息中的值
notification.setProductId(firstNonBlank(notification.getProductId(), text(txNode, "productId", "product_id")));
notification.setOriginalTransactionId(firstNonBlank(notification.getOriginalTransactionId(), text(txNode, "originalTransactionId", "original_transaction_id")));
// 将时间戳转换为 ISO 8601 格式
notification.setPurchaseDate(epochToIso(txNode, "purchaseDate", "purchase_date"));
notification.setExpiresDate(epochToIso(txNode, "expiresDate", "expires_date"));
}
return notification;
} catch (IllegalArgumentException e) {
throw new BusinessException(ErrorCode.PARAMS_ERROR, e.getMessage());
} catch (Exception e) {
log.error("Failed to decode signedPayload", e);
throw new BusinessException(ErrorCode.OPERATION_ERROR, "signedPayload 解析失败");
}
}
/**
* 从 JSON 节点中提取文本值
* 支持多个候选键名,按顺序尝试获取第一个非空值
*
* @param node JSON 节点
* @param keys 候选的键名列表(支持驼峰和下划线命名)
* @return 找到的第一个非空文本值,如果都不存在则返回 null
*/
private String text(JsonNode node, String... keys) {
for (String k : keys) {
if (node != null && node.has(k) && !node.get(k).isNull()) {
return node.get(k).asText();
}
}
return null;
}
/**
* 返回第一个非空白的字符串
* 用于在多个可选值中选择优先级更高的非空值
*
* @param a 第一优先级的字符串
* @param b 第二优先级的字符串
* @return 第一个非空白的字符串,如果都为空则返回第一个参数
*/
private String firstNonBlank(String a, String b) {
if (a != null && !a.isBlank()) {
return a;
}
return (b != null && !b.isBlank()) ? b : a;
}
/**
* 将 Unix 时间戳(毫秒)转换为 ISO 8601 格式字符串
* 从 JSON 节点中提取时间戳并转换为标准 ISO 格式
*
* @param node JSON 节点
* @param keys 候选的时间戳字段键名
* @return ISO 8601 格式的时间字符串,如果解析失败则返回原始值
*/
private String epochToIso(JsonNode node, String... keys) {
String val = text(node, keys);
if (val == null) {
return null;
}
try {
// 解析时间戳并转换为 ISO 格式
long epochMillis = Long.parseLong(val);
return java.time.Instant.ofEpochMilli(epochMillis).toString();
} catch (NumberFormatException e) {
// 如果不是有效的数字,直接返回原值
return val;
}
}
} }

View File

@@ -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()
);
} }

View File

@@ -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);
}
} }

View File

@@ -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;
@@ -42,6 +43,9 @@ public class UserController {
@Resource @Resource
private UserService userService; private UserService userService;
@Resource
private com.yolo.keyborad.service.KeyboardFeedbackService feedbackService;
/** /**
* 苹果登录 * 苹果登录
* *
@@ -57,6 +61,7 @@ public class UserController {
@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);
} }
@@ -94,8 +99,7 @@ public class UserController {
@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")
@@ -116,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));
}
} }

View File

@@ -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);
}
}
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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> {
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -1,5 +1,6 @@
package com.yolo.keyborad.service; package com.yolo.keyborad.service;
import com.apple.itunes.storekit.model.ResponseBodyV2DecodedPayload;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult; import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
/** /**
@@ -16,9 +17,30 @@ public interface ApplePurchaseService {
void processPurchase(Long userId, AppleReceiptValidationResult validationResult); void processPurchase(Long userId, AppleReceiptValidationResult validationResult);
/** /**
* 处理苹果服务器续订通知(无收据,仅基于原始交易号和商品信息 * 处理订阅相关通知(新订阅、续订、续订失败、过期等
* *
* @param notification 通知内容 * @param notification 解码后的通知载荷
*/ */
void processRenewNotification(com.yolo.keyborad.model.dto.AppleServerNotification notification); void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification);
/**
* 处理退款相关通知(退款、退款拒绝、退款撤销)
*
* @param notification 解码后的通知载荷
*/
void handleRefundNotification(ResponseBodyV2DecodedPayload notification);
/**
* 处理续订偏好变更通知
*
* @param notification 解码后的通知载荷
*/
void handleRenewalPreferenceChange(ResponseBodyV2DecodedPayload notification);
/**
* 处理消费请求通知
*
* @param notification 解码后的通知载荷
*/
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
} }

View File

@@ -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);
} }

View File

@@ -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);
} }

View File

@@ -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>{
}

View File

@@ -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);
} }

View File

@@ -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>{
}

View File

@@ -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>{
}

View File

@@ -1,5 +1,11 @@
package com.yolo.keyborad.service.impl; 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.common.ErrorCode;
import com.yolo.keyborad.exception.BusinessException; import com.yolo.keyborad.exception.BusinessException;
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult; import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
@@ -51,6 +57,9 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
@Resource @Resource
private UserService userService; private UserService userService;
@Resource
private SignedDataVerifier signedDataVerifier;
/** /**
* 处理苹果购买(订阅或内购) * 处理苹果购买(订阅或内购)
@@ -70,6 +79,7 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) { public void processPurchase(Long userId, AppleReceiptValidationResult validationResult) {
// 1. 校验收据有效性 // 1. 校验收据有效性
if (validationResult == null || !validationResult.isValid()) { if (validationResult == null || !validationResult.isValid()) {
log.error("Invalid receipt, reason={}", validationResult.getReason());
throw new BusinessException(ErrorCode.RECEIPT_INVALID); throw new BusinessException(ErrorCode.RECEIPT_INVALID);
} }
@@ -114,72 +124,460 @@ public class ApplePurchaseServiceImpl implements ApplePurchaseService {
/** /**
* 处理苹果订阅续期通知 * 处理订阅相关通知
* 当用户的订阅自动续费时,苹果会发送通知到我们的服务器 * 包括:新订阅、续订、续订失败、过期、宽限期过期、优惠兑换、续订延长等
* *
* @param notification 苹果服务器通知对象,包含续订交易信息 * @param notification 解码后的通知载荷
* @throws BusinessException 当参数缺失、商品不存在或更新失败时抛出
*/ */
@Override @Override
@Transactional(rollbackFor = Exception.class) @Transactional(rollbackFor = Exception.class)
public void processRenewNotification(AppleServerNotification notification) { public void handleSubscriptionNotification(ResponseBodyV2DecodedPayload notification) {
// 参数校验确保通知对象和原始交易ID不为空 if (notification == null || notification.getData() == null) {
if (notification == null || notification.getOriginalTransactionId() == null) { log.warn("Subscription notification data is null");
throw new BusinessException(ErrorCode.LACK_ORIGIN_TRANSACTION_ID_ERROR);
}
// 根据原始交易ID查询历史购买记录
// 原始交易ID可能对应多条记录首次购买 + 多次续订),取最新的一条
List<KeyboardUserPurchaseRecords> records = purchaseRecordsService.lambdaQuery()
.eq(KeyboardUserPurchaseRecords::getOriginalTransactionId, notification.getOriginalTransactionId())
.orderByDesc(KeyboardUserPurchaseRecords::getId)
.last("LIMIT 1")
.list();
// 如果找不到匹配的购买记录,记录警告并返回
if (records == null || records.isEmpty()) {
log.warn("Renewal notification without matching purchase record, originalTransactionId={}", notification.getOriginalTransactionId());
return;
}
KeyboardUserPurchaseRecords record = records.get(0);
// 根据商品ID获取商品信息
KeyboardProductItems product = productItemsService.getProductEntityByProductId(notification.getProductId());
// 校验商品是否存在且类型为订阅类型
if (product == null || !"subscription".equalsIgnoreCase(product.getType())) {
log.warn("Renewal notification ignored, product not subscription or not found. productId={}", notification.getProductId());
return; 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(); KeyboardUserPurchaseRecords renewRecord = new KeyboardUserPurchaseRecords();
renewRecord.setUserId(record.getUserId()); renewRecord.setUserId(userId.intValue());
renewRecord.setProductId(product.getProductId()); renewRecord.setProductId(product.getProductId());
renewRecord.setPurchaseQuantity(product.getDurationValue()); renewRecord.setPurchaseQuantity(product.getDurationValue());
renewRecord.setPrice(product.getPrice()); renewRecord.setPrice(product.getPrice());
renewRecord.setCurrency(product.getCurrency()); renewRecord.setCurrency(product.getCurrency());
renewRecord.setPurchaseTime(toDate(parseInstant(notification.getPurchaseDate())));
renewRecord.setPurchaseType(product.getType()); renewRecord.setPurchaseType(product.getType());
renewRecord.setStatus("PAID"); // 续订状态默认为已支付 renewRecord.setStatus("PAID");
renewRecord.setPaymentMethod("APPLE"); renewRecord.setPaymentMethod("APPLE");
renewRecord.setTransactionId(notification.getTransactionId()); // 新的交易ID renewRecord.setTransactionId(transaction.getTransactionId());
renewRecord.setOriginalTransactionId(notification.getOriginalTransactionId()); // 原始交易ID renewRecord.setOriginalTransactionId(transaction.getOriginalTransactionId());
renewRecord.setProductIds(new String[]{product.getProductId()}); renewRecord.setProductIds(new String[]{product.getProductId()});
// 解析并设置订阅过期时间 if (transaction.getPurchaseDate() != null) {
Instant expiresInstant = parseInstant(notification.getExpiresDate()); renewRecord.setPurchaseTime(Date.from(Instant.ofEpochMilli(transaction.getPurchaseDate())));
if (expiresInstant != null) { 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)); renewRecord.setExpiresDate(Date.from(expiresInstant));
} }
renewRecord.setEnvironment(notification.getEnvironment());
renewRecord.setPurchaseDate(toDate(parseInstant(notification.getPurchaseDate())));
// 保存续订记录到数据库 if (transaction.getEnvironment() != null) {
renewRecord.setEnvironment(transaction.getEnvironment().name());
}
purchaseRecordsService.save(renewRecord); purchaseRecordsService.save(renewRecord);
// 延长用户VIP有效期 // 延长VIP有效期
extendVip(record.getUserId().longValue(), product, expiresInstant); 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);
} }

View File

@@ -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();
} }

View File

@@ -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()
);
}
} }

View File

@@ -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));
// 缓存到Redis7天过期
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));
// 缓存到Redis7天过期
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);
}); });
} }
} }

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.mapper.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{
}

View File

@@ -157,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());
}
} }

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.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{
}

View File

@@ -0,0 +1,18 @@
package com.yolo.keyborad.service.impl;
import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Autowired;
import java.util.List;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import com.yolo.keyborad.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{
}

View File

@@ -3,6 +3,8 @@ 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.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;
@@ -35,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;
}
/** /**
* 插入/更新一条向量数据 * 插入/更新一条向量数据
* *
@@ -179,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",

View File

@@ -5,19 +5,18 @@ 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.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.KeyboardUserLoginLogService;
import com.yolo.keyborad.service.KeyboardUserWalletService;
import com.yolo.keyborad.service.UserService;
import jakarta.servlet.http.HttpServletRequest; 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;
@@ -60,6 +59,15 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
@Resource @Resource
private KeyboardUserLoginLogService loginLogService; 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(
@@ -77,16 +85,31 @@ public class UserServiceImpl extends ServiceImpl<KeyboardUserMapper, KeyboardUse
keyboardUserMapper.insert(keyboardUser); keyboardUserMapper.insert(keyboardUser);
keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId()); keyboardCharacterService.addDefaultUserCharacter(keyboardUser.getId());
// 初始化用户钱包 AppConfig appConfig = cfgHolder.getRef().get();
// 初始化用户钱包余额为0
KeyboardUserWallet wallet = new KeyboardUserWallet(); KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId()); wallet.setUserId(keyboardUser.getId());
wallet.setBalance(BigDecimal.ZERO); wallet.setBalance(BigDecimal.valueOf(appConfig.getUserRegisterProperties().getFreeTrialQuota()));
wallet.setVersion(0); wallet.setVersion(0);
wallet.setStatus((short) 1); wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date()); wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date()); wallet.setUpdatedAt(new Date());
walletService.save(wallet); 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;
} }
@@ -195,21 +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(); KeyboardUserWallet wallet = new KeyboardUserWallet();
wallet.setUserId(keyboardUser.getId()); wallet.setUserId(keyboardUser.getId());
wallet.setBalance(BigDecimal.ZERO); wallet.setBalance(appConfig.getUserRegisterProperties().getRewardBalance());
wallet.setVersion(0); wallet.setVersion(0);
wallet.setStatus((short) 1); wallet.setStatus((short) 1);
wallet.setCreatedAt(new Date()); wallet.setCreatedAt(new Date());
wallet.setUpdatedAt(new Date()); wallet.setUpdatedAt(new Date());
walletService.save(wallet); 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;
} }

View File

@@ -57,8 +57,19 @@ public class JwtParser {
// 获取第一个证书Base64 编码标准格式非URL安全格式 // 获取第一个证书Base64 编码标准格式非URL安全格式
String certBase64 = x5cArray.getString(0); 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 安全编码) // x5c 中的证书使用标准 Base64 编码(非 URL 安全编码)
byte[] certBytes = Base64.getDecoder().decode(certBase64); 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 证书并提取公钥 // 生成 X509 证书并提取公钥
CertificateFactory certFactory = CertificateFactory.getInstance("X.509"); CertificateFactory certFactory = CertificateFactory.getInstance("X.509");

View File

@@ -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 风格(默认可取值:uuidsimple-uuidrandom-32random-64random-128tik # 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

View File

@@ -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

View File

@@ -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

View 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>

View 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>

View File

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.yolo.keyborad.mapper.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>