Compare commits
53 Commits
3d559e8903
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e657a22b10 | |||
| d654777a02 | |||
| c8f8311cae | |||
| 20f8d9c152 | |||
| 52727dfd7c | |||
| 9b4819900d | |||
| cdfeace2f1 | |||
| 3665596c1f | |||
| 06e7828b85 | |||
| b83957e0bc | |||
| da3ee94924 | |||
| e027918387 | |||
| 02dd37ffaf | |||
| dbc7ee365d | |||
| 5220a22cbd | |||
| 83cb65a31f | |||
| 0555f1d0df | |||
| 33b5de3e07 | |||
| 9bd9a2646f | |||
| aa6cc25ab4 | |||
| 7a4086547d | |||
| 9d4e86535a | |||
| 1e71a3b17c | |||
| a5c86010a1 | |||
| 4d23dd2bba | |||
| 2a8199d933 | |||
| 20694b2171 | |||
| ca670287d8 | |||
| 7b4a874283 | |||
| 14806a9437 | |||
| b1ef4ee192 | |||
| eaf015fe48 | |||
| 1fa24f7e34 | |||
| db38fe819c | |||
| 40d846ec0d | |||
| 283eb4fa49 | |||
| 99cf132d76 | |||
| 742107f944 | |||
| b146e3a122 | |||
| e46ae429fd | |||
| e51d3a8e25 | |||
| df1113575a | |||
| a2354fba92 | |||
| 6748ff0796 | |||
| 51555c3bb2 | |||
| 8baeacae3b | |||
| 4519343139 | |||
| 49a58e6f87 | |||
| 147c05a6f0 | |||
| 5e9873bf72 | |||
| c3768caae6 | |||
| 506e1e0192 | |||
| e2fdd1637f |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ build/
|
||||
/docs/websocket-api.md
|
||||
/src/main/resources/static/ws-test.html
|
||||
/.omc/
|
||||
/logs/
|
||||
/src/main/resources/sql/google-play-iap.sql
|
||||
/docs/google-play-iap-integration.md
|
||||
|
||||
257
docs/google-play-iap-integration.md
Normal file
257
docs/google-play-iap-integration.md
Normal file
@@ -0,0 +1,257 @@
|
||||
# Google Play IAP 服务端集成
|
||||
|
||||
## 系统设计
|
||||
|
||||
### 目标
|
||||
|
||||
- 客户端购买成功后,服务端使用 `purchaseToken` 二次校验 Google Play Developer API。
|
||||
- RTDN 进入后,先做 Pub/Sub 来源校验,再二次查询 Google Play Developer API,同步本地订单、token、权益状态。
|
||||
- 对订阅与一次性商品统一落库,保证幂等、防重放和可审计。
|
||||
|
||||
### 核心流程
|
||||
|
||||
1. 客户端发起购买,BillingClient 中建议设置 `obfuscatedAccountId = 当前 userId`。
|
||||
2. 客户端支付成功后调用 `/api/google-play/purchases/verify`。
|
||||
3. 服务端调用 Google Play Developer API:
|
||||
- 订阅:`purchases.subscriptionsv2.get`
|
||||
- 一次性商品:`purchases.productsv2.getproductpurchasev2`
|
||||
4. 服务端更新 `google_play_purchase_token`、`google_play_order`、`google_play_user_entitlement`。
|
||||
5. 对需要确认的订单执行 acknowledge / consume。
|
||||
6. Google Play RTDN 推送到 `/api/google-play/rtdn`。
|
||||
7. webhook 校验 topic / subscription / OIDC JWT 后,再次查询 Developer API,再更新本地状态。
|
||||
|
||||
### 幂等策略
|
||||
|
||||
- RTDN 事件以 `message_id` 幂等,重复消息直接忽略。
|
||||
- 订单以 `order_key` 幂等:
|
||||
- 有 `google_order_id` 时用订单号。
|
||||
- 没有订单号时回退为 `TOKEN:{purchaseToken}`。
|
||||
- 购买 token 表以 `purchase_token` 唯一。
|
||||
- 一次性商品发货前先看 `delivery_status`,已发货不重复发。
|
||||
- 订阅不做“累加式延长”,而是直接把用户 VIP 到期时间同步为 Google 返回的最新 `expiryTime`,天然幂等。
|
||||
|
||||
### 本地权益映射
|
||||
|
||||
- `subscription` -> `VIP_SUBSCRIPTION`
|
||||
- 一次性商品且 `duration_days > 0` 或 `unit` 包含 `vip/member/会员` -> `VIP_ONE_TIME`
|
||||
- 一次性商品且 `unit` 像 `coin/quota/credit/金币/次数`,或商品名/`durationValue` 可解析数值 -> `WALLET_TOP_UP`
|
||||
- 其他一次性商品 -> `NON_CONSUMABLE`
|
||||
|
||||
## 目录结构
|
||||
|
||||
```text
|
||||
src/main/java/com/yolo/keyborad/
|
||||
├── config/
|
||||
│ ├── GooglePlayHttpConfig.java
|
||||
│ └── GooglePlayProperties.java
|
||||
├── controller/
|
||||
│ └── GooglePlayController.java
|
||||
├── googleplay/
|
||||
│ ├── GooglePlayApiClient.java
|
||||
│ ├── GooglePlayApiException.java
|
||||
│ ├── GooglePlayConstants.java
|
||||
│ ├── GooglePlayEntitlementApplier.java
|
||||
│ ├── GooglePlayPubSubAuthService.java
|
||||
│ ├── GooglePlayServiceAccountTokenProvider.java
|
||||
│ ├── GooglePlayStateService.java
|
||||
│ └── model/
|
||||
│ ├── GooglePlayPurchaseSnapshot.java
|
||||
│ ├── GooglePlaySyncCommand.java
|
||||
│ └── GooglePlaySyncResult.java
|
||||
├── mapper/
|
||||
│ ├── GooglePlayOrderMapper.java
|
||||
│ ├── GooglePlayPurchaseTokenMapper.java
|
||||
│ ├── GooglePlayRtdnEventMapper.java
|
||||
│ └── GooglePlayUserEntitlementMapper.java
|
||||
├── model/
|
||||
│ ├── dto/googleplay/
|
||||
│ │ ├── GooglePlayPubSubPushRequest.java
|
||||
│ │ └── GooglePlayPurchaseVerifyReq.java
|
||||
│ ├── entity/googleplay/
|
||||
│ │ ├── GooglePlayOrder.java
|
||||
│ │ ├── GooglePlayPurchaseToken.java
|
||||
│ │ ├── GooglePlayRtdnEvent.java
|
||||
│ │ └── GooglePlayUserEntitlement.java
|
||||
│ └── vo/googleplay/
|
||||
│ └── GooglePlayPurchaseVerifyResp.java
|
||||
└── service/
|
||||
├── GooglePlayBillingService.java
|
||||
└── impl/
|
||||
└── GooglePlayBillingServiceImpl.java
|
||||
```
|
||||
|
||||
## 建表 SQL
|
||||
|
||||
- 文件:`src/main/resources/sql/google-play-iap.sql`
|
||||
|
||||
四张表职责:
|
||||
|
||||
- `google_play_order`:每一笔 Google 订单/续费周期的最终状态与发货状态。
|
||||
- `google_play_purchase_token`:一个 purchase token 的最新状态快照。
|
||||
- `google_play_user_entitlement`:用户实际拥有的本地权益。
|
||||
- `google_play_rtdn_event`:RTDN 审计与重试记录。
|
||||
|
||||
## 接口定义
|
||||
|
||||
### 1. 客户端购买校验
|
||||
|
||||
- `POST /api/google-play/purchases/verify`
|
||||
- 需要登录态与签名
|
||||
|
||||
请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"packageName": "com.example.app",
|
||||
"productId": "vip_monthly",
|
||||
"productType": "subscription",
|
||||
"purchaseToken": "xxxx"
|
||||
}
|
||||
```
|
||||
|
||||
响应体:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"userId": 1001,
|
||||
"productId": "vip_monthly",
|
||||
"productType": "SUBSCRIPTION",
|
||||
"purchaseToken": "xxxx",
|
||||
"orderId": "GPA.1234-5678-9012-34567",
|
||||
"orderState": "ACTIVE",
|
||||
"entitlementState": "ACTIVE",
|
||||
"deliveryStatus": "NOT_REQUIRED",
|
||||
"accessGranted": true,
|
||||
"acknowledged": true,
|
||||
"consumed": false,
|
||||
"expiryTime": "2026-04-18T10:00:00.000+00:00",
|
||||
"lastSyncedAt": "2026-03-18T10:01:00.000+00:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 2. RTDN Webhook
|
||||
|
||||
- `POST /api/google-play/rtdn`
|
||||
- 不需要登录态,不走 app 签名,单独做 Pub/Sub 校验
|
||||
|
||||
Pub/Sub Push body:
|
||||
|
||||
```json
|
||||
{
|
||||
"message": {
|
||||
"messageId": "136969346945",
|
||||
"data": "base64-encoded-json"
|
||||
},
|
||||
"subscription": "projects/your-project/subscriptions/google-play-rtdn-push"
|
||||
}
|
||||
```
|
||||
|
||||
关键请求头:
|
||||
|
||||
- `Authorization: Bearer <OIDC JWT>`
|
||||
- `X-Goog-Topic: projects/your-project/topics/google-play-rtdn`
|
||||
|
||||
成功响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": true
|
||||
}
|
||||
```
|
||||
|
||||
## RTDN 状态处理
|
||||
|
||||
### 订阅
|
||||
|
||||
- `SUBSCRIPTION_PURCHASED`
|
||||
- Developer API 状态通常为 `ACTIVE`
|
||||
- 新建/更新 token、order、VIP 权益
|
||||
- 如未 acknowledge,则服务端补 acknowledge
|
||||
|
||||
- `SUBSCRIPTION_RENEWED`
|
||||
- 再查 `subscriptionsv2.get`
|
||||
- 取最新 `expiryTime` 覆盖本地 VIP 到期时间
|
||||
- 不做“在旧时间上加时长”,避免 RTDN 重放导致多发
|
||||
|
||||
- `SUBSCRIPTION_CANCELED`
|
||||
- 若 `subscriptionState=CANCELED` 但 `expiryTime` 仍未来
|
||||
- 本地仍保留 VIP,`autoRenewEnabled=false`
|
||||
- 到期后等待 `EXPIRED`
|
||||
|
||||
- `SUBSCRIPTION_IN_GRACE_PERIOD`
|
||||
- 本地继续保留 VIP
|
||||
- entitlement state 记为 `IN_GRACE_PERIOD`
|
||||
|
||||
- `SUBSCRIPTION_ON_HOLD`
|
||||
- 立即取消本地 VIP 可用态
|
||||
- 保留 token/order 记录,等待恢复或过期
|
||||
|
||||
- `SUBSCRIPTION_RESTARTED` / `SUBSCRIPTION_RECOVERED`
|
||||
- 再查最新状态
|
||||
- 若恢复为 `ACTIVE`,重新开启 VIP
|
||||
|
||||
- `SUBSCRIPTION_PAUSED`
|
||||
- 本地取消 VIP 可用态
|
||||
- 记录 `autoResumeTime`
|
||||
|
||||
- `SUBSCRIPTION_EXPIRED`
|
||||
- 本地关闭 VIP
|
||||
- entitlement state -> `EXPIRED`
|
||||
|
||||
- `SUBSCRIPTION_REVOKED`
|
||||
- 本地关闭 VIP
|
||||
- entitlement state -> `REVOKED`
|
||||
|
||||
- `SUBSCRIPTION_PENDING_PURCHASE_CANCELED`
|
||||
- 用当前 token 查到 `linkedPurchaseToken` 后,再同步旧 token
|
||||
- 旧订阅权益继续保持
|
||||
|
||||
### 一次性商品
|
||||
|
||||
- `ONE_TIME_PRODUCT_PURCHASED`
|
||||
- 若映射为钱包充值:仅当 `delivery_status != DELIVERED` 才入账
|
||||
- 若映射为一次性 VIP 或非消耗型权益:同样只发一次
|
||||
- 钱包型商品在发货后执行 consume
|
||||
- 非消耗型商品执行 acknowledge
|
||||
|
||||
- `ONE_TIME_PRODUCT_CANCELED`
|
||||
- 一般是 pending 订单被取消
|
||||
- 本地标记为 `CANCELED`,不发货
|
||||
|
||||
- `VoidedPurchaseNotification`
|
||||
- 先二次查询 Developer API;若 404,则结合本地 token/order 记录做最终回滚
|
||||
- 订阅按 `REVOKED`
|
||||
- 一次性商品按 `REFUNDED`
|
||||
- 钱包余额不足以回滚时,订单标记 `MANUAL_REVIEW`,日志显式报错
|
||||
|
||||
## 安全校验
|
||||
|
||||
- RTDN 入口校验 `X-Goog-Topic`
|
||||
- 校验 `subscription` 字段
|
||||
- 调用 `tokeninfo` 校验 Pub/Sub OIDC JWT
|
||||
- 校验 `aud`
|
||||
- 校验 `email` 与 `email_verified`
|
||||
- 校验 `iss` 必须是 Google Accounts
|
||||
- 客户端校验接口可要求 `obfuscatedExternalAccountId == userId`
|
||||
|
||||
## 测试样例
|
||||
|
||||
建议执行:
|
||||
|
||||
```bash
|
||||
mvn -q test -Dtest=GooglePlay*
|
||||
```
|
||||
|
||||
覆盖点:
|
||||
|
||||
- 订阅首次购买与续费
|
||||
- 订阅取消但未过期
|
||||
- 订阅宽限期
|
||||
- 一次性商品发货幂等
|
||||
- RTDN 重放幂等
|
||||
BIN
src/main/.DS_Store
vendored
BIN
src/main/.DS_Store
vendored
Binary file not shown.
@@ -1,7 +1,10 @@
|
||||
package com.yolo.keyborad.interceptor;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.yolo.keyborad.config.AppConfig;
|
||||
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||
import com.yolo.keyborad.utils.SignUtils;
|
||||
import jakarta.servlet.DispatcherType;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
@@ -25,16 +28,19 @@ public class SignInterceptor implements HandlerInterceptor {
|
||||
private final ObjectMapper signValueObjectMapper = new ObjectMapper()
|
||||
.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||
|
||||
// 允许时间误差 5 分钟
|
||||
private static final long ALLOW_TIME_DIFF_SECONDS = 300;
|
||||
// 允许时间误差 20秒
|
||||
private static final long ALLOW_TIME_DIFF_SECONDS = 200;
|
||||
// nonce 在 Redis 的有效期(建议比时间误差略长一点)
|
||||
private static final long NONCE_EXPIRE_SECONDS = 300;
|
||||
|
||||
public SignInterceptor(Map<String, String> appSecretMap,
|
||||
StringRedisTemplate redisTemplate) {
|
||||
StringRedisTemplate redisTemplate,
|
||||
NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||
this.appSecretMap = appSecretMap;
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.cfgHolder = cfgHolder;
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -43,6 +49,9 @@ public class SignInterceptor implements HandlerInterceptor {
|
||||
if (request.getDispatcherType() != DispatcherType.REQUEST) {
|
||||
return true;
|
||||
}
|
||||
if (shouldSkipSignValidation()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
String appId = request.getHeader("X-App-Id");
|
||||
String timestamp = request.getHeader("X-Timestamp");
|
||||
@@ -121,6 +130,21 @@ public class SignInterceptor implements HandlerInterceptor {
|
||||
return true;
|
||||
}
|
||||
|
||||
private boolean shouldSkipSignValidation() {
|
||||
AppConfig appConfig = cfgHolder.getRef().get();
|
||||
if (appConfig == null || appConfig.getSkipUser() == null) {
|
||||
return false;
|
||||
}
|
||||
List<Integer> skipList = appConfig.getSkipUser().getSkipList();
|
||||
if (skipList == null || skipList.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
long userId = StpUtil.getLoginIdAsLong();
|
||||
return skipList.stream()
|
||||
.filter(Objects::nonNull)
|
||||
.anyMatch(skipUserId -> skipUserId.longValue() == userId);
|
||||
}
|
||||
|
||||
private String stringifyForSign(Object value) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.yolo.keyborad;
|
||||
|
||||
import com.yolo.keyborad.common.xfile.ByteFileWrapperAdapter;
|
||||
import com.yolo.keyborad.config.AppleAppStoreProperties;
|
||||
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.dromara.x.file.storage.core.tika.ContentTypeDetect;
|
||||
import org.dromara.x.file.storage.spring.EnableFileStorage;
|
||||
@@ -14,7 +15,7 @@ import org.springframework.context.annotation.Bean;
|
||||
|
||||
@Slf4j
|
||||
@SpringBootApplication
|
||||
@EnableConfigurationProperties(AppleAppStoreProperties.class)
|
||||
@EnableConfigurationProperties({AppleAppStoreProperties.class, GooglePlayProperties.class})
|
||||
@EnableFileStorage
|
||||
public class MyApplication {
|
||||
public static void main(String[] args) {
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
package com.yolo.keyborad.aop;
|
||||
|
||||
import cn.dev33.satoken.stp.StpLogic;
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.yolo.keyborad.utils.RequestIpUtils;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
@@ -40,9 +43,12 @@ public class LogInterceptor {
|
||||
// 获取请求参数
|
||||
Object[] args = point.getArgs();
|
||||
String reqParam = "[" + StringUtils.join(args, ", ") + "]";
|
||||
String tokenString = httpServletRequest.getHeader("auth-token");
|
||||
Object userId = StpUtil.getLoginIdByToken(tokenString);
|
||||
String ip = RequestIpUtils.resolveClientIp(httpServletRequest);
|
||||
// 输出请求日志
|
||||
log.info("request start,id: {}, path: {}, ip: {}, params: {}", requestId, url,
|
||||
httpServletRequest.getRemoteHost(), reqParam);
|
||||
log.info("request start,id: {}, userId:{}, path: {}, ip: {}, params: {}",requestId, userId, url,
|
||||
ip, reqParam);
|
||||
// 执行原方法
|
||||
Object result = point.proceed();
|
||||
// 输出响应日志
|
||||
|
||||
@@ -76,9 +76,17 @@ public enum ErrorCode {
|
||||
AUDIO_FILE_TOO_LARGE(40017, "音频文件过大"),
|
||||
AUDIO_FORMAT_NOT_SUPPORTED(40018, "音频格式不支持"),
|
||||
STT_SERVICE_ERROR(50031, "语音转文字服务异常"),
|
||||
GOOGLE_PLAY_NOT_ENABLED(50032, "Google Play 服务未启用"),
|
||||
GOOGLE_PLAY_PACKAGE_NAME_MISSING(50033, "Google Play packageName 未配置"),
|
||||
GOOGLE_PLAY_PURCHASE_MISMATCH(50034, "Google Play 购买信息不匹配"),
|
||||
GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED(50035, "Google Play RTDN 来源校验失败"),
|
||||
GOOGLE_PLAY_RTDN_PAYLOAD_INVALID(50036, "Google Play RTDN payload 非法"),
|
||||
GOOGLE_PLAY_STATE_INVALID(50037, "Google Play 购买状态非法"),
|
||||
REPORT_TYPE_INVALID(40020, "举报类型无效"),
|
||||
REPORT_COMPANION_ID_EMPTY(40021, "被举报的AI角色ID不能为空"),
|
||||
REPORT_TYPE_EMPTY(40022, "举报类型不能为空");
|
||||
REPORT_TYPE_EMPTY(40022, "举报类型不能为空"),
|
||||
ACCOUNT_RECENTLY_CANCELLED(50038, "账号注销未满7天,暂不允许注册"),
|
||||
VERSION_NOT_FOUND(40022, "未找到可用的版本配置");
|
||||
|
||||
/**
|
||||
* 状态码
|
||||
@@ -103,4 +111,4 @@ public enum ErrorCode {
|
||||
public String getCodeAsString() {
|
||||
return String.valueOf(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.yolo.keyborad.config;
|
||||
import lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
@@ -19,6 +20,14 @@ public class AppConfig {
|
||||
|
||||
private inviteConfig inviteConfig = new inviteConfig();
|
||||
|
||||
private customerMailConfig customerMailConfig = new customerMailConfig();
|
||||
|
||||
private skipUser skipUser = new skipUser();
|
||||
|
||||
private LLmModel llmModel = new LLmModel();
|
||||
|
||||
|
||||
|
||||
@Data
|
||||
public static class UserRegisterProperties {
|
||||
|
||||
@@ -39,6 +48,11 @@ public class AppConfig {
|
||||
private Integer vectorSearchLimit = 1;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class skipUser{
|
||||
private List<Integer> skipList = null;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class LLmConfig {
|
||||
//LLM系统提示语
|
||||
@@ -52,10 +66,22 @@ public class AppConfig {
|
||||
|
||||
//聊天消息最大长度
|
||||
private Integer maxMessageLength = 1000;
|
||||
|
||||
private String companionSystemPrompt = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class inviteConfig {
|
||||
private String h5Link = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class customerMailConfig{
|
||||
private String customerMail = "";
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class LLmModel{
|
||||
private String model = "";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yolo.keyborad.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.net.http.HttpClient;
|
||||
import java.time.Duration;
|
||||
|
||||
@Configuration
|
||||
public class GooglePlayHttpConfig {
|
||||
|
||||
@Bean
|
||||
public HttpClient googlePlayHttpClient() {
|
||||
return HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(10))
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yolo.keyborad.config;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
@Data
|
||||
@ConfigurationProperties(prefix = "google.play")
|
||||
public class GooglePlayProperties {
|
||||
|
||||
private boolean enabled;
|
||||
|
||||
private String packageName;
|
||||
|
||||
private String serviceAccountKeyPath;
|
||||
|
||||
private String oauthTokenUri = "https://oauth2.googleapis.com/token";
|
||||
|
||||
private String androidPublisherScope = "https://www.googleapis.com/auth/androidpublisher";
|
||||
|
||||
private String pubsubTokenInfoUri = "https://oauth2.googleapis.com/tokeninfo";
|
||||
|
||||
private boolean validatePubsubJwt = true;
|
||||
|
||||
private boolean requireObfuscatedAccountId = false;
|
||||
|
||||
private Pubsub pubsub = new Pubsub();
|
||||
|
||||
@Data
|
||||
public static class Pubsub {
|
||||
private String expectedTopic;
|
||||
private String expectedSubscription;
|
||||
private String audience;
|
||||
private String serviceAccountEmail;
|
||||
}
|
||||
}
|
||||
@@ -24,12 +24,16 @@ import java.util.Map;
|
||||
@Configuration
|
||||
public class LLMConfig {
|
||||
|
||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||
|
||||
@Value("${spring.ai.openai.api-key}")
|
||||
private String apiKey;
|
||||
@Value("${spring.ai.openai.base-url}")
|
||||
private String baseUrl;
|
||||
@Value("${spring.ai.openai.chat.options.model}")
|
||||
private String openRouterChatModel;
|
||||
|
||||
public LLMConfig(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||
this.cfgHolder = cfgHolder;
|
||||
}
|
||||
|
||||
@Bean
|
||||
public OpenAiApi openAiApi() {
|
||||
@@ -44,7 +48,7 @@ public class LLMConfig {
|
||||
public ChatClient chatClient(ChatModel geminiModel) {
|
||||
return ChatClient.builder(geminiModel)
|
||||
.defaultOptions(OpenAiChatOptions.builder()
|
||||
.model(openRouterChatModel)
|
||||
.model(getConfiguredChatModel())
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
@@ -64,4 +68,9 @@ public class LLMConfig {
|
||||
RetryUtils.DEFAULT_RETRY_TEMPLATE);
|
||||
}
|
||||
|
||||
private String getConfiguredChatModel() {
|
||||
AppConfig appConfig = cfgHolder.getRef().get();
|
||||
return appConfig.getLlmModel().getModel();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -22,9 +22,12 @@ import java.util.HashMap;
|
||||
public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
|
||||
private final StringRedisTemplate redisTemplate;
|
||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||
|
||||
public SaTokenConfigure(StringRedisTemplate redisTemplate) {
|
||||
public SaTokenConfigure(StringRedisTemplate redisTemplate,
|
||||
NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||
this.redisTemplate = redisTemplate;
|
||||
this.cfgHolder = cfgHolder;
|
||||
}
|
||||
|
||||
HashMap<String, String> appSecretMap = new HashMap<>();
|
||||
@@ -53,7 +56,7 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
.addPathPatterns("/**")
|
||||
.excludePathPatterns(getExcludePaths());
|
||||
appSecretMap.put(appId, appSecret);
|
||||
registry.addInterceptor(new SignInterceptor(appSecretMap,redisTemplate))
|
||||
registry.addInterceptor(new SignInterceptor(appSecretMap, redisTemplate, cfgHolder))
|
||||
.addPathPatterns("/**") // 需要签名校验的接口
|
||||
.excludePathPatterns(getExcludePaths()); // 不需要校验的接口;
|
||||
}
|
||||
@@ -74,13 +77,20 @@ public class SaTokenConfigure implements WebMvcConfigurer {
|
||||
"/user/appleLogin",
|
||||
"/user/logout",
|
||||
"/tag/list",
|
||||
"/user/sendVerifyMail",
|
||||
"/character/detail",
|
||||
"/user/login",
|
||||
"/user/register",
|
||||
"/user/verifyMailCode",
|
||||
"/character/listWithNotLogin",
|
||||
"/character/listByTagWithNotLogin",
|
||||
"/ai-companion/report",
|
||||
"/apple/notification"
|
||||
"/apple/notification",
|
||||
"/google-play/rtdn",
|
||||
"/appVersions/checkUpdate",
|
||||
"/appVersions/checkUpdate",
|
||||
"/character/detailWithNotLogin",
|
||||
"/apple/validate-receipt"
|
||||
};
|
||||
}
|
||||
@Bean
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentBlockReq;
|
||||
import com.yolo.keyborad.model.vo.CommentBlockedUserVO;
|
||||
import com.yolo.keyborad.service.KeyboardCommentBlockRelationService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/23
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/ai-companion/comment/block")
|
||||
@Tag(name = "AI陪聊角色评论拉黑", description = "AI陪聊角色评论区拉黑管理接口")
|
||||
public class AiCompanionCommentBlockController {
|
||||
|
||||
@Resource
|
||||
private KeyboardCommentBlockRelationService commentBlockRelationService;
|
||||
|
||||
@PostMapping("/add")
|
||||
@Operation(summary = "拉黑评论用户", description = "在评论区拉黑指定用户")
|
||||
public BaseResponse<Boolean> blockUser(@RequestBody CommentBlockReq req) {
|
||||
Long blockedUserId = validateBlockedUserId(req);
|
||||
Long blockerUserId = StpUtil.getLoginIdAsLong();
|
||||
commentBlockRelationService.blockUser(blockerUserId, blockedUserId);
|
||||
return ResultUtils.success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/cancel")
|
||||
@Operation(summary = "取消拉黑评论用户", description = "取消在评论区对指定用户的拉黑")
|
||||
public BaseResponse<Boolean> unblockUser(@RequestBody CommentBlockReq req) {
|
||||
Long blockedUserId = validateBlockedUserId(req);
|
||||
Long blockerUserId = StpUtil.getLoginIdAsLong();
|
||||
commentBlockRelationService.unblockUser(blockerUserId, blockedUserId);
|
||||
return ResultUtils.success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "查询已拉黑用户列表", description = "查询当前登录用户在评论区已拉黑的用户列表")
|
||||
public BaseResponse<List<CommentBlockedUserVO>> listBlockedUsers() {
|
||||
Long blockerUserId = StpUtil.getLoginIdAsLong();
|
||||
return ResultUtils.success(commentBlockRelationService.listBlockedUsers(blockerUserId));
|
||||
}
|
||||
|
||||
private Long validateBlockedUserId(CommentBlockReq req) {
|
||||
if (req == null || req.getBlockedUserId() == null) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "被拉黑用户ID不能为空");
|
||||
}
|
||||
return req.getBlockedUserId();
|
||||
}
|
||||
}
|
||||
@@ -8,9 +8,12 @@ import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentAddReq;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentDeleteReq;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentLikeReq;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentPageReq;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentReportReq;
|
||||
import com.yolo.keyborad.model.vo.CommentVO;
|
||||
import com.yolo.keyborad.service.KeyboardAiCommentReportService;
|
||||
import com.yolo.keyborad.service.KeyboardAiCompanionCommentService;
|
||||
import com.yolo.keyborad.service.KeyboardAiCompanionCommentLikeService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -35,6 +38,9 @@ public class AiCompanionCommentController {
|
||||
@Resource
|
||||
private KeyboardAiCompanionCommentLikeService commentLikeService;
|
||||
|
||||
@Resource
|
||||
private KeyboardAiCommentReportService commentReportService;
|
||||
|
||||
@PostMapping("/add")
|
||||
@Operation(summary = "发表评论", description = "用户对AI陪聊角色发表评论")
|
||||
public BaseResponse<Long> addComment(@RequestBody CommentAddReq req) {
|
||||
@@ -51,6 +57,18 @@ public class AiCompanionCommentController {
|
||||
return ResultUtils.success(commentId);
|
||||
}
|
||||
|
||||
@PostMapping("/delete")
|
||||
@Operation(summary = "删除评论", description = "删除当前用户自己发布的评论;若删除根评论,其下回复将不会继续展示")
|
||||
public BaseResponse<Boolean> deleteComment(@RequestBody CommentDeleteReq req) {
|
||||
if (req == null || req.getCommentId() == null) {
|
||||
throw new BusinessException(ErrorCode.COMMENT_ID_EMPTY);
|
||||
}
|
||||
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
commentService.deleteComment(userId, req.getCommentId());
|
||||
return ResultUtils.success(true);
|
||||
}
|
||||
|
||||
@PostMapping("/page")
|
||||
@Operation(summary = "分页查询评论", description = "分页查询AI陪聊角色的评论列表,包含当前用户是否已点赞状态")
|
||||
public BaseResponse<IPage<CommentVO>> pageComments(@RequestBody CommentPageReq req) {
|
||||
@@ -75,4 +93,12 @@ public class AiCompanionCommentController {
|
||||
boolean result = commentLikeService.toggleLike(userId, req.getCommentId());
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@PostMapping("/report")
|
||||
@Operation(summary = "举报评论", description = "举报AI陪聊角色评论,支持多种举报类型(可多选):1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他")
|
||||
public BaseResponse<Long> reportComment(@RequestBody CommentReportReq req) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
Long reportId = commentReportService.reportComment(userId, req);
|
||||
return ResultUtils.success(reportId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,9 +42,17 @@ public class AiCompanionController {
|
||||
|
||||
@PostMapping("/page")
|
||||
@Operation(summary = "分页查询AI陪聊角色", description = "分页查询已上线的AI陪聊角色列表,包含点赞数、评论数和当前用户点赞状态")
|
||||
public BaseResponse<IPage<AiCompanionVO>> pageList(@RequestBody PageDTO pageDTO) {
|
||||
public BaseResponse<IPage<AiCompanionVO>> pageList(
|
||||
@RequestBody PageDTO pageDTO,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
IPage<AiCompanionVO> result = aiCompanionService.pageListWithLikeStatus(userId, pageDTO.getPageNum(), pageDTO.getPageSize());
|
||||
IPage<AiCompanionVO> result = aiCompanionService.pageListWithLikeStatus(
|
||||
userId,
|
||||
pageDTO.getPageNum(),
|
||||
pageDTO.getPageSize(),
|
||||
acceptLanguage
|
||||
);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@@ -62,28 +70,35 @@ public class AiCompanionController {
|
||||
|
||||
@GetMapping("/liked")
|
||||
@Operation(summary = "获取当前用户点赞过的AI角色列表", description = "查询当前用户点赞过的所有AI角色,返回角色详细信息")
|
||||
public BaseResponse<List<AiCompanionVO>> getLikedCompanions() {
|
||||
public BaseResponse<List<AiCompanionVO>> getLikedCompanions(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
List<AiCompanionVO> result = aiCompanionService.getLikedCompanions(userId);
|
||||
List<AiCompanionVO> result = aiCompanionService.getLikedCompanions(userId, acceptLanguage);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/chatted")
|
||||
@Operation(summary = "获取当前用户聊过天的AI角色列表", description = "查询当前用户聊过天的所有AI角色,返回角色详细信息")
|
||||
public BaseResponse<List<AiCompanionVO>> getChattedCompanions() {
|
||||
public BaseResponse<List<AiCompanionVO>> getChattedCompanions(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
List<AiCompanionVO> result = aiCompanionService.getChattedCompanions(userId);
|
||||
List<AiCompanionVO> result = aiCompanionService.getChattedCompanions(userId, acceptLanguage);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/{companionId}")
|
||||
@Operation(summary = "根据ID获取AI角色详情", description = "根据AI角色ID查询角色详细信息,包含点赞数、评论数和当前用户点赞状态")
|
||||
public BaseResponse<AiCompanionVO> getCompanionById(@PathVariable Long companionId) {
|
||||
public BaseResponse<AiCompanionVO> getCompanionById(
|
||||
@PathVariable Long companionId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
if (companionId == null) {
|
||||
throw new BusinessException(ErrorCode.COMPANION_ID_EMPTY);
|
||||
}
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
AiCompanionVO result = aiCompanionService.getCompanionById(userId, companionId);
|
||||
AiCompanionVO result = aiCompanionService.getCompanionById(userId, companionId, acceptLanguage);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.AppleReceiptValidationResult;
|
||||
import com.yolo.keyborad.service.ApplePurchaseService;
|
||||
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
|
||||
import com.yolo.keyborad.service.AppleReceiptService;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
@@ -23,11 +24,14 @@ public class AppleReceiptController {
|
||||
|
||||
private final AppleReceiptService appleReceiptService;
|
||||
private final ApplePurchaseService applePurchaseService;
|
||||
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
|
||||
|
||||
public AppleReceiptController(AppleReceiptService appleReceiptService,
|
||||
ApplePurchaseService applePurchaseService) {
|
||||
ApplePurchaseService applePurchaseService,
|
||||
KeyboardUserPurchaseRecordsService purchaseRecordsService) {
|
||||
this.appleReceiptService = appleReceiptService;
|
||||
this.applePurchaseService = applePurchaseService;
|
||||
this.purchaseRecordsService = purchaseRecordsService;
|
||||
}
|
||||
|
||||
@PostMapping("/receipt")
|
||||
@@ -85,4 +89,26 @@ public class AppleReceiptController {
|
||||
return ResultUtils.success(Boolean.TRUE);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查购买记录是否存在
|
||||
* 根据 transactionId 和 originalTransactionId 查询购买记录
|
||||
*
|
||||
* @param body 请求体,包含 transactionId 和 originalTransactionId
|
||||
* @return 存在返回 true,不存在返回 false
|
||||
*/
|
||||
@PostMapping("/check-purchase")
|
||||
public BaseResponse<Boolean> checkPurchaseExists(@RequestBody Map<String, String> body) {
|
||||
if (body == null) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "body 不能为空");
|
||||
}
|
||||
String transactionId = body.get("transactionId");
|
||||
String originalTransactionId = body.get("originalTransactionId");
|
||||
if ((transactionId == null || transactionId.isBlank())
|
||||
&& (originalTransactionId == null || originalTransactionId.isBlank())) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "transactionId 和 originalTransactionId 不能同时为空");
|
||||
}
|
||||
|
||||
boolean exists = purchaseRecordsService.checkPurchaseExists(transactionId, originalTransactionId);
|
||||
return ResultUtils.success(exists);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.userCharacter.KeyboardUserCharacterAddDTO;
|
||||
import com.yolo.keyborad.model.dto.userCharacter.KeyboardUserCharacterDTO;
|
||||
import com.yolo.keyborad.model.dto.userCharacter.KeyboardUserCharacterSortUpdateDTO;
|
||||
import com.yolo.keyborad.model.entity.KeyboardCharacter;
|
||||
import com.yolo.keyborad.model.entity.KeyboardTag;
|
||||
import com.yolo.keyborad.model.entity.KeyboardUserCharacter;
|
||||
import com.yolo.keyborad.model.vo.character.KeyboardCharacterRespVO;
|
||||
import com.yolo.keyborad.model.vo.character.KeyboardUserCharacterVO;
|
||||
import com.yolo.keyborad.model.vo.tags.TagsRespVO;
|
||||
import com.yolo.keyborad.service.KeyboardCharacterService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.chat.model.ChatModel;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
import java.util.List;
|
||||
|
||||
/*
|
||||
@@ -42,26 +31,36 @@ public class CharacterController {
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "人设列表", description = "人设列表接口按 rank 排名")
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> list() {
|
||||
return ResultUtils.success(characterService.selectListWithRank());
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> list(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.selectListWithRank(acceptLanguage));
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@Operation(summary = "人设详情", description = "人设详情接口")
|
||||
public BaseResponse<KeyboardCharacterRespVO> detail(@RequestParam("id") Long id) {
|
||||
return ResultUtils.success(characterService.getDetailById(id));
|
||||
public BaseResponse<KeyboardCharacterRespVO> detail(
|
||||
@RequestParam("id") Long id,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.getDetailById(id, acceptLanguage));
|
||||
}
|
||||
|
||||
@GetMapping("/listByTag")
|
||||
@Operation(summary = "按标签查询人设列表", description = "按标签查询人设列表接口")
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listByTag(@RequestParam("tagId") Long tagId) {
|
||||
return ResultUtils.success(characterService.selectListByTag(tagId));
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listByTag(
|
||||
@RequestParam("tagId") Long tagId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.selectListByTag(tagId, acceptLanguage));
|
||||
}
|
||||
|
||||
@GetMapping("/listByUser")
|
||||
@Operation(summary = "用户人设列表", description = "用户人设列表接口")
|
||||
public BaseResponse<List<KeyboardUserCharacterVO>> userList() {
|
||||
return ResultUtils.success(characterService.selectListByUserId());
|
||||
public BaseResponse<List<KeyboardUserCharacterVO>> userList(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.selectListByUserId(acceptLanguage));
|
||||
}
|
||||
|
||||
|
||||
@@ -89,20 +88,27 @@ public class CharacterController {
|
||||
|
||||
@GetMapping("/listWithNotLogin")
|
||||
@Operation(summary = "未登录用户人设列表", description = "未登录用户人设列表接口按 rank 排名")
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listWithNotLogin() {
|
||||
return ResultUtils.success(characterService.selectListWithNotLoginRank());
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listWithNotLogin(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.selectListWithNotLoginRank(acceptLanguage));
|
||||
}
|
||||
|
||||
@GetMapping("/detailWithNotLogin")
|
||||
@Operation(summary = "未登录用户人设详情", description = "未登录用户人设详情接口")
|
||||
public BaseResponse<KeyboardCharacterRespVO> detailWithNotLogin(@RequestParam("id") Long id) {
|
||||
KeyboardCharacter character = characterService.getById(id);
|
||||
return ResultUtils.success(BeanUtil.copyProperties(character, KeyboardCharacterRespVO.class));
|
||||
public BaseResponse<KeyboardCharacterRespVO> detailWithNotLogin(
|
||||
@RequestParam("id") Long id,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.getDetailById(id, acceptLanguage));
|
||||
}
|
||||
|
||||
@GetMapping("/listByTagWithNotLogin")
|
||||
@Operation(summary = "未登录用户按标签查询人设列表", description = "未登录用户按标签查询人设列表接口")
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listByTagWithNotLogin(@RequestParam("tagId") Long tagId) {
|
||||
return ResultUtils.success(characterService.selectListByTagWithNotLogin(tagId));
|
||||
public BaseResponse<List<KeyboardCharacterRespVO>> listByTagWithNotLogin(
|
||||
@RequestParam("tagId") Long tagId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(characterService.selectListByTagWithNotLogin(tagId, acceptLanguage));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +97,7 @@ public class ChatController {
|
||||
return Flux.defer(() -> chatService.talk(chatReq))
|
||||
.onErrorResume(e -> {
|
||||
log.error("聊天流式接口异常", e);
|
||||
String message = StrUtil.isBlank(e.getMessage()) ? "服务暂时不可用,请稍后重试" : e.getMessage();
|
||||
String message = StrUtil.isBlank(e.getMessage()) ? "service down,please wait a moment" : e.getMessage();
|
||||
return Flux.just(
|
||||
ServerSentEvent.builder(new ChatStreamMessage("error", message))
|
||||
.event("error")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq;
|
||||
import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp;
|
||||
import com.yolo.keyborad.service.GooglePlayBillingService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/google-play")
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayController {
|
||||
|
||||
private final GooglePlayBillingService googlePlayBillingService;
|
||||
|
||||
@PostMapping("/purchases/verify")
|
||||
public BaseResponse<GooglePlayPurchaseVerifyResp> verify(@RequestBody GooglePlayPurchaseVerifyReq req) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
return ResultUtils.success(googlePlayBillingService.verifyPurchase(userId, req));
|
||||
}
|
||||
|
||||
@PostMapping("/rtdn")
|
||||
public BaseResponse<Boolean> handleRtdn(HttpServletRequest request,
|
||||
@RequestBody GooglePlayPubSubPushRequest pushRequest) {
|
||||
googlePlayBillingService.handleRtdn(request, pushRequest);
|
||||
return ResultUtils.success(Boolean.TRUE);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.model.dto.appversion.KeyboardAppUpdateCheckReq;
|
||||
import com.yolo.keyborad.model.vo.appversion.KeyboardAppUpdateCheckRespVO;
|
||||
import com.yolo.keyborad.service.KeyboardAppVersionsService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/4
|
||||
*/
|
||||
@RestController
|
||||
@Slf4j
|
||||
@RequestMapping("/appVersions")
|
||||
@Tag(name = "App版本", description = "App 更新检查")
|
||||
public class KeyboardAppVersionsController {
|
||||
|
||||
@Resource
|
||||
private KeyboardAppVersionsService keyboardAppVersionsService;
|
||||
|
||||
@PostMapping("/checkUpdate")
|
||||
@Operation(summary = "检查是否需要更新", description = "根据平台/渠道/客户端版本号,返回是否需要更新与是否强更")
|
||||
public BaseResponse<KeyboardAppUpdateCheckRespVO> checkUpdate(@RequestBody KeyboardAppUpdateCheckReq req) {
|
||||
final KeyboardAppUpdateCheckRespVO resp = keyboardAppVersionsService.checkUpdate(req);
|
||||
return ResultUtils.success(resp);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.model.vo.warning.KeyboardWarningMessageRespVO;
|
||||
import com.yolo.keyborad.service.KeyboardWarningMessageService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/2/28
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/keyboardWarningMessage")
|
||||
@Tag(name = "注销提示信息")
|
||||
public class KeyboardWarningMessageController {
|
||||
|
||||
private final KeyboardWarningMessageService keyboardWarningMessageService;
|
||||
|
||||
public KeyboardWarningMessageController(KeyboardWarningMessageService keyboardWarningMessageService) {
|
||||
this.keyboardWarningMessageService = keyboardWarningMessageService;
|
||||
}
|
||||
|
||||
@GetMapping("/byLocale")
|
||||
@Operation(summary = "按 locale 查询提示信息", description = "根据 locale 查询用户注销提示信息")
|
||||
public BaseResponse<KeyboardWarningMessageRespVO> getByLocale(
|
||||
@Parameter(description = "地区/语言标识,例如:zh-CN、en-US", required = true)
|
||||
@RequestParam("locale") String locale
|
||||
) {
|
||||
return ResultUtils.success(keyboardWarningMessageService.getByLocale(locale));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
@@ -31,14 +32,14 @@ public class ProductsController {
|
||||
private KeyboardProductItemsService productItemsService;
|
||||
|
||||
@GetMapping("/detail")
|
||||
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情")
|
||||
@Operation(summary = "查询商品明细", description = "根据商品ID或productId查询商品详情,通过platform区分平台")
|
||||
public BaseResponse<KeyboardProductItemRespVO> getProductDetail(
|
||||
@RequestParam(value = "id", required = false) Long id,
|
||||
@RequestParam(value = "productId", required = false) String productId
|
||||
) {
|
||||
@RequestParam(value = "productId", required = false) String productId) {
|
||||
if (id == null && (productId == null || productId.isBlank())) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "id 或 productId 至少传一个");
|
||||
}
|
||||
// 判断平台:如果是android返回安卓商品,否则默认返回苹果商品
|
||||
KeyboardProductItemRespVO result = (id != null)
|
||||
? productItemsService.getProductDetailById(id)
|
||||
: productItemsService.getProductDetailByProductId(productId);
|
||||
@@ -47,25 +48,29 @@ public class ProductsController {
|
||||
|
||||
@GetMapping("/listByType")
|
||||
@Operation(summary = "按类型查询商品列表", description = "根据商品类型查询商品列表,type=all 返回全部")
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(@RequestParam("type") String type) {
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listByType(
|
||||
@RequestParam("type") String type,
|
||||
@RequestHeader(value = "platform", required = false) String platform) {
|
||||
if (type == null || type.isBlank()) {
|
||||
throw new BusinessException(ErrorCode.PARAMS_ERROR, "type 不能为空");
|
||||
}
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type);
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType(type, platform);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/inApp/list")
|
||||
@Operation(summary = "查询内购商品列表", description = "查询 type=in-app-purchase 的商品列表")
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases() {
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase");
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listInAppPurchases(
|
||||
@RequestHeader(value = "platform", required = false) String platform) {
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("in-app-purchase", platform);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/subscription/list")
|
||||
@Operation(summary = "查询订阅商品列表", description = "查询 type=subscription 的商品列表")
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions() {
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription");
|
||||
public BaseResponse<List<KeyboardProductItemRespVO>> listSubscriptions(
|
||||
@RequestHeader(value = "platform", required = false) String platform) {
|
||||
List<KeyboardProductItemRespVO> result = productItemsService.listProductsByType("subscription", platform);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
package com.yolo.keyborad.controller;
|
||||
|
||||
import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.model.entity.KeyboardTag;
|
||||
import com.yolo.keyborad.model.vo.tags.TagsRespVO;
|
||||
import com.yolo.keyborad.service.TagService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
@@ -12,6 +9,7 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@@ -32,8 +30,9 @@ public class TagController {
|
||||
|
||||
@GetMapping("/list")
|
||||
@Operation(summary = "标签列表", description = "标签列表接口")
|
||||
public BaseResponse<List<TagsRespVO>> list() {
|
||||
List<KeyboardTag> list = tagService.selectList();
|
||||
return ResultUtils.success(BeanUtil.copyToList(list, TagsRespVO.class));
|
||||
public BaseResponse<List<TagsRespVO>> list(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
return ResultUtils.success(tagService.selectList(acceptLanguage));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.yolo.keyborad.model.vo.themes.KeyboardThemesRespVO;
|
||||
import com.yolo.keyborad.service.KeyboardThemePurchaseService;
|
||||
import com.yolo.keyborad.service.KeyboardThemeStylesService;
|
||||
import com.yolo.keyborad.service.KeyboardThemesService;
|
||||
import com.yolo.keyborad.utils.RequestLocaleUtils;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
@@ -42,14 +43,22 @@ public class ThemesController {
|
||||
|
||||
@GetMapping("/listByStyle")
|
||||
@Operation(summary = "按风格查询主题", description = "按主题风格查询主题列表接口")
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> listByStyle(@RequestParam("themeStyle") Long themeStyleId) {
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> listByStyle(
|
||||
@RequestParam("themeStyle") Long themeStyleId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
return ResultUtils.success(themesService.selectThemesByStyle(themeStyleId,userId));}
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
return ResultUtils.success(themesService.selectThemesByStyle(themeStyleId, userId, local));
|
||||
}
|
||||
|
||||
@GetMapping("/listAllStyles")
|
||||
@Operation(summary = "查询所有主题风格", description = "查询所有主题风格列表接口")
|
||||
public BaseResponse<List<KeyboardThemeStylesRespVO>> listAllStyles() {
|
||||
return ResultUtils.success(keyboardThemeStylesService.selectAllThemeStyles());
|
||||
public BaseResponse<List<KeyboardThemeStylesRespVO>> listAllStyles(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
return ResultUtils.success(keyboardThemeStylesService.selectAllThemeStyles(local));
|
||||
}
|
||||
|
||||
@PostMapping("/purchase")
|
||||
@@ -70,33 +79,48 @@ public class ThemesController {
|
||||
|
||||
@GetMapping("/purchased")
|
||||
@Operation(summary = "查询已购买的主题", description = "查询当前用户已购买的主题列表")
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> getPurchasedThemes() {
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> getPurchasedThemes(
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
List<KeyboardThemesRespVO> result = themePurchaseService.getUserPurchasedThemes(userId);
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
List<KeyboardThemesRespVO> result = themePurchaseService.getUserPurchasedThemes(userId, local);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/detail")
|
||||
@Operation(summary = "查询主题详情", description = "根据主题ID查询主题详情")
|
||||
public BaseResponse<KeyboardThemesRespVO> getThemeDetail(@RequestParam Long themeId) {
|
||||
public BaseResponse<KeyboardThemesRespVO> getThemeDetail(
|
||||
@RequestParam Long themeId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
KeyboardThemesRespVO result = themesService.getThemeDetail(themeId, userId);
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
KeyboardThemesRespVO result = themesService.getThemeDetail(themeId, userId, local);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/recommended")
|
||||
@Operation(summary = "推荐主题列表", description = "按真实下载数量降序返回推荐主题")
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> getRecommendedThemes(@RequestParam(required = false) Long themeId) {
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> getRecommendedThemes(
|
||||
@RequestParam(required = false) Long themeId,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
List<KeyboardThemesRespVO> result = themesService.getRecommendedThemes(userId, themeId);
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
List<KeyboardThemesRespVO> result = themesService.getRecommendedThemes(userId, themeId, local);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
@GetMapping("/search")
|
||||
@Operation(summary = "搜索主题", description = "根据主题名称模糊搜索主题")
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> searchThemes(@RequestParam String themeName) {
|
||||
public BaseResponse<List<KeyboardThemesRespVO>> searchThemes(
|
||||
@RequestParam String themeName,
|
||||
@RequestHeader(value = "Accept-Language", required = false) String acceptLanguage
|
||||
) {
|
||||
Long userId = StpUtil.getLoginIdAsLong();
|
||||
List<KeyboardThemesRespVO> result = themesService.searchThemesByName(themeName, userId);
|
||||
String local = RequestLocaleUtils.resolveLanguage(acceptLanguage);
|
||||
List<KeyboardThemesRespVO> result = themesService.searchThemesByName(themeName, userId, local);
|
||||
return ResultUtils.success(result);
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ import cn.dev33.satoken.stp.StpUtil;
|
||||
import cn.hutool.core.bean.BeanUtil;
|
||||
import com.yolo.keyborad.common.BaseResponse;
|
||||
import com.yolo.keyborad.common.ResultUtils;
|
||||
import com.yolo.keyborad.config.AppConfig;
|
||||
import com.yolo.keyborad.config.NacosAppConfigCenter;
|
||||
import com.yolo.keyborad.model.dto.AppleLoginReq;
|
||||
import com.yolo.keyborad.model.dto.user.*;
|
||||
import com.yolo.keyborad.model.entity.KeyboardFeedback;
|
||||
@@ -22,7 +24,6 @@ import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.annotation.Resource;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.text.SimpleDateFormat;
|
||||
@@ -51,8 +52,11 @@ public class UserController {
|
||||
@Resource
|
||||
private KeyboardUserInviteCodesService inviteCodesService;
|
||||
|
||||
@Value("${customer_mail}")
|
||||
private String customerMail;
|
||||
private final NacosAppConfigCenter.DynamicAppConfig cfgHolder;
|
||||
|
||||
public UserController(NacosAppConfigCenter.DynamicAppConfig cfgHolder) {
|
||||
this.cfgHolder = cfgHolder;
|
||||
}
|
||||
/**
|
||||
* 苹果登录
|
||||
*
|
||||
@@ -164,6 +168,7 @@ public class UserController {
|
||||
@GetMapping("/customerMail")
|
||||
@Operation(summary = "获取客服邮箱", description = "获取 customer_mail 配置的客服邮箱地址")
|
||||
public BaseResponse<String> getCustomerMail() {
|
||||
return ResultUtils.success(customerMail);
|
||||
AppConfig appConfig = cfgHolder.getRef().get();
|
||||
return ResultUtils.success(appConfig.getCustomerMailConfig().getCustomerMail());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayApiClient {
|
||||
|
||||
private static final String API_BASE = "https://androidpublisher.googleapis.com/androidpublisher/v3/applications";
|
||||
private static final int MAX_RETRY = 3;
|
||||
private static final long RETRY_INTERVAL_MILLIS = 300L;
|
||||
|
||||
private final GooglePlayServiceAccountTokenProvider tokenProvider;
|
||||
private final HttpClient googlePlayHttpClient;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final GooglePlayProperties properties;
|
||||
|
||||
public GooglePlayPurchaseSnapshot getSubscription(String packageName, String purchaseToken) {
|
||||
String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptionsv2/tokens/" + encode(purchaseToken);
|
||||
JsonNode jsonNode = executeJson("GET_SUBSCRIPTION", url, null, true);
|
||||
return mapSubscriptionSnapshot(packageName, purchaseToken, jsonNode);
|
||||
}
|
||||
|
||||
public GooglePlayPurchaseSnapshot getOneTimeProduct(String packageName, String purchaseToken) {
|
||||
String url = API_BASE + "/" + encode(packageName) + "/purchases/productsv2/tokens/" + encode(purchaseToken);
|
||||
JsonNode jsonNode = executeJson("GET_ONE_TIME", url, null, true);
|
||||
return mapOneTimeSnapshot(packageName, purchaseToken, jsonNode);
|
||||
}
|
||||
|
||||
public void acknowledgeSubscription(String packageName, String productId, String purchaseToken) {
|
||||
String url = API_BASE + "/" + encode(packageName) + "/purchases/subscriptions/"
|
||||
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge";
|
||||
executeJson("ACK_SUBSCRIPTION", url, "{}", true);
|
||||
}
|
||||
|
||||
public void acknowledgeProduct(String packageName, String productId, String purchaseToken) {
|
||||
String url = API_BASE + "/" + encode(packageName) + "/purchases/products/"
|
||||
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":acknowledge";
|
||||
executeJson("ACK_PRODUCT", url, "{}", true);
|
||||
}
|
||||
|
||||
public void consumeProduct(String packageName, String productId, String purchaseToken) {
|
||||
String url = API_BASE + "/" + encode(packageName) + "/purchases/products/"
|
||||
+ encode(productId) + "/tokens/" + encode(purchaseToken) + ":consume";
|
||||
executeJson("CONSUME_PRODUCT", url, "{}", true);
|
||||
}
|
||||
|
||||
public JsonNode verifyPubSubJwt(String bearerToken) {
|
||||
String url = properties.getPubsubTokenInfoUri() + "?id_token=" + encode(bearerToken);
|
||||
return executeJson("VERIFY_PUBSUB_JWT", url, null, false);
|
||||
}
|
||||
|
||||
private JsonNode executeJson(String operation, String url, String body, boolean withAuthorization) {
|
||||
for (int attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||
try {
|
||||
HttpRequest request = buildRequest(url, body, withAuthorization);
|
||||
HttpResponse<String> response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new GooglePlayApiException(response.statusCode(), response.body());
|
||||
}
|
||||
String responseBody = response.body();
|
||||
return responseBody == null || responseBody.isBlank()
|
||||
? objectMapper.createObjectNode()
|
||||
: objectMapper.readTree(responseBody);
|
||||
} catch (GooglePlayApiException e) {
|
||||
if (attempt == MAX_RETRY || e.getStatusCode() == 404) {
|
||||
throw e;
|
||||
}
|
||||
sleep(attempt, operation, e);
|
||||
} catch (Exception e) {
|
||||
if (attempt == MAX_RETRY) {
|
||||
throw new GooglePlayApiException("Google Play 请求失败: " + operation, e);
|
||||
}
|
||||
sleep(attempt, operation, e);
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("unreachable");
|
||||
}
|
||||
|
||||
private HttpRequest buildRequest(String url, String body, boolean withAuthorization) {
|
||||
HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create(url))
|
||||
.header("Accept", "application/json");
|
||||
if (withAuthorization) {
|
||||
builder.header("Authorization", "Bearer " + tokenProvider.getAccessToken());
|
||||
}
|
||||
if (body == null) {
|
||||
return builder.GET().build();
|
||||
}
|
||||
return builder.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
}
|
||||
|
||||
private GooglePlayPurchaseSnapshot mapSubscriptionSnapshot(String packageName, String purchaseToken, JsonNode root) {
|
||||
JsonNode lineItem = firstLineItem(root);
|
||||
String state = mapSubscriptionState(text(root, "subscriptionState"));
|
||||
String googleOrderId = firstNonBlank(text(lineItem, "latestSuccessfulOrderId"), text(root, "latestOrderId"));
|
||||
return GooglePlayPurchaseSnapshot.builder()
|
||||
.packageName(packageName)
|
||||
.productId(text(lineItem, "productId"))
|
||||
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
|
||||
.basePlanId(text(lineItem.path("offerDetails"), "basePlanId"))
|
||||
.purchaseToken(purchaseToken)
|
||||
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
||||
.googleOrderId(googleOrderId)
|
||||
.linkedPurchaseToken(text(root, "linkedPurchaseToken"))
|
||||
.state(state)
|
||||
.acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState")))
|
||||
.consumptionState(GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE)
|
||||
.quantity(1)
|
||||
.refundableQuantity(null)
|
||||
.autoRenewEnabled(bool(lineItem.path("autoRenewingPlan"), "autoRenewEnabled"))
|
||||
.accessGranted(isSubscriptionAccessGranted(state))
|
||||
.externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId"))
|
||||
.externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId"))
|
||||
.regionCode(text(root, "regionCode"))
|
||||
.canceledStateReason(resolveCanceledReason(root.path("canceledStateContext")))
|
||||
.startTime(parseTime(firstNonBlank(text(root, "startTime"), text(lineItem, "startTime"))))
|
||||
.expiryTime(parseTime(text(lineItem, "expiryTime")))
|
||||
.autoResumeTime(parseTime(text(root.path("pausedStateContext"), "autoResumeTime")))
|
||||
.lastSyncedAt(new Date())
|
||||
.rawResponse(writeJson(root))
|
||||
.build();
|
||||
}
|
||||
|
||||
private GooglePlayPurchaseSnapshot mapOneTimeSnapshot(String packageName, String purchaseToken, JsonNode root) {
|
||||
// 1. 获取 productLineItem 数组的第一个元素 (index 0)
|
||||
JsonNode firstLineItem = root.path("productLineItem").path(0);
|
||||
// 2. 获取嵌套的 productOfferDetails 对象
|
||||
JsonNode offerDetails = firstLineItem.path("productOfferDetails");
|
||||
|
||||
String state = mapOneTimeState(text(root.path("purchaseStateContext"), "purchaseState"));
|
||||
String purchaseOptionId = text(offerDetails, "purchaseOptionId");
|
||||
// 修正:一次性购买的订单号字段名为 "orderId"
|
||||
String googleOrderId = text(root, "orderId");
|
||||
|
||||
return GooglePlayPurchaseSnapshot.builder()
|
||||
.packageName(packageName)
|
||||
// 修正:从第一个元素中获取 productId
|
||||
.productId(text(firstLineItem, "productId"))
|
||||
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
||||
.purchaseToken(purchaseToken)
|
||||
.purchaseOptionId(purchaseOptionId)
|
||||
.orderKey(resolveOrderKey(googleOrderId, purchaseToken))
|
||||
.googleOrderId(googleOrderId)
|
||||
.linkedPurchaseToken(null)
|
||||
.state(state)
|
||||
.acknowledgementState(mapAcknowledgementState(text(root, "acknowledgementState")))
|
||||
// 修正:从 productOfferDetails 中获取以下字段
|
||||
.consumptionState(mapConsumptionState(text(offerDetails, "consumptionState")))
|
||||
.quantity(number(offerDetails, "quantity"))
|
||||
.refundableQuantity(number(offerDetails, "refundableQuantity"))
|
||||
.autoRenewEnabled(false)
|
||||
.accessGranted(GooglePlayConstants.STATE_ACTIVE.equals(state))
|
||||
.externalAccountId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalAccountId"))
|
||||
.externalProfileId(text(root.path("externalAccountIdentifiers"), "obfuscatedExternalProfileId"))
|
||||
.regionCode(text(root, "regionCode"))
|
||||
.canceledStateReason(null)
|
||||
.startTime(parseTime(text(root, "purchaseCompletionTime")))
|
||||
.expiryTime(null)
|
||||
.autoResumeTime(null)
|
||||
.lastSyncedAt(new Date())
|
||||
.rawResponse(writeJson(root))
|
||||
.build();
|
||||
}
|
||||
|
||||
private JsonNode firstLineItem(JsonNode root) {
|
||||
JsonNode lineItems = root.path("lineItems");
|
||||
return lineItems.isArray() && !lineItems.isEmpty() ? lineItems.get(0) : objectMapper.createObjectNode();
|
||||
}
|
||||
|
||||
private String resolveOrderKey(String googleOrderId, String purchaseToken) {
|
||||
return firstNonBlank(googleOrderId, "TOKEN:" + purchaseToken);
|
||||
}
|
||||
|
||||
private String resolveCanceledReason(JsonNode node) {
|
||||
if (node == null || node.isMissingNode()) {
|
||||
return null;
|
||||
}
|
||||
if (node.hasNonNull("userInitiatedCancellation")) {
|
||||
return "USER";
|
||||
}
|
||||
if (node.hasNonNull("systemInitiatedCancellation")) {
|
||||
return "SYSTEM";
|
||||
}
|
||||
if (node.hasNonNull("developerInitiatedCancellation")) {
|
||||
return "DEVELOPER";
|
||||
}
|
||||
if (node.hasNonNull("replacementCancellation")) {
|
||||
return "REPLACEMENT";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private String mapSubscriptionState(String state) {
|
||||
return switch (state) {
|
||||
case "SUBSCRIPTION_STATE_ACTIVE" -> GooglePlayConstants.STATE_ACTIVE;
|
||||
case "SUBSCRIPTION_STATE_CANCELED" -> GooglePlayConstants.STATE_CANCELED;
|
||||
case "SUBSCRIPTION_STATE_IN_GRACE_PERIOD" -> GooglePlayConstants.STATE_IN_GRACE_PERIOD;
|
||||
case "SUBSCRIPTION_STATE_ON_HOLD" -> GooglePlayConstants.STATE_ON_HOLD;
|
||||
case "SUBSCRIPTION_STATE_PAUSED" -> GooglePlayConstants.STATE_PAUSED;
|
||||
case "SUBSCRIPTION_STATE_EXPIRED" -> GooglePlayConstants.STATE_EXPIRED;
|
||||
case "SUBSCRIPTION_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING;
|
||||
case "SUBSCRIPTION_STATE_PENDING_PURCHASE_CANCELED" -> GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED;
|
||||
default -> GooglePlayConstants.STATE_UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
private boolean isSubscriptionAccessGranted(String state) {
|
||||
return GooglePlayConstants.STATE_ACTIVE.equals(state)
|
||||
|| GooglePlayConstants.STATE_CANCELED.equals(state)
|
||||
|| GooglePlayConstants.STATE_IN_GRACE_PERIOD.equals(state);
|
||||
}
|
||||
|
||||
private String mapOneTimeState(String state) {
|
||||
if (state == null) {
|
||||
return GooglePlayConstants.STATE_UNKNOWN;
|
||||
}
|
||||
return switch (state) {
|
||||
// productsv2 API 返回短枚举名
|
||||
case "PURCHASED", "PURCHASE_STATE_PURCHASED" -> GooglePlayConstants.STATE_ACTIVE;
|
||||
case "PENDING", "PURCHASE_STATE_PENDING" -> GooglePlayConstants.STATE_PENDING;
|
||||
case "CANCELLED", "PURCHASE_STATE_CANCELLED" -> GooglePlayConstants.STATE_CANCELED;
|
||||
default -> GooglePlayConstants.STATE_UNKNOWN;
|
||||
};
|
||||
}
|
||||
|
||||
private String mapAcknowledgementState(String state) {
|
||||
return "ACKNOWLEDGEMENT_STATE_ACKNOWLEDGED".equals(state)
|
||||
? GooglePlayConstants.ACK_ACKNOWLEDGED
|
||||
: GooglePlayConstants.ACK_PENDING;
|
||||
}
|
||||
|
||||
private String mapConsumptionState(String state) {
|
||||
if ("CONSUMPTION_STATE_CONSUMED".equals(state)) {
|
||||
return GooglePlayConstants.CONSUMPTION_CONSUMED;
|
||||
}
|
||||
if ("CONSUMPTION_STATE_NO_NEED_TO_CONSUME".equals(state)) {
|
||||
return GooglePlayConstants.CONSUMPTION_NOT_APPLICABLE;
|
||||
}
|
||||
return GooglePlayConstants.CONSUMPTION_PENDING;
|
||||
}
|
||||
|
||||
private String text(JsonNode node, String field) {
|
||||
JsonNode child = node == null ? null : node.get(field);
|
||||
return child == null || child.isNull() ? null : child.asText();
|
||||
}
|
||||
|
||||
private Boolean bool(JsonNode node, String field) {
|
||||
JsonNode child = node == null ? null : node.get(field);
|
||||
return child == null || child.isNull() ? null : child.asBoolean();
|
||||
}
|
||||
|
||||
private Integer number(JsonNode node, String field) {
|
||||
JsonNode child = node == null ? null : node.get(field);
|
||||
return child == null || child.isNull() ? null : child.asInt();
|
||||
}
|
||||
|
||||
private Date parseTime(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Date.from(Instant.parse(value));
|
||||
}
|
||||
|
||||
private String firstNonBlank(String first, String second) {
|
||||
return first != null && !first.isBlank() ? first : second;
|
||||
}
|
||||
|
||||
private String encode(String raw) {
|
||||
return URLEncoder.encode(raw, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private String writeJson(JsonNode jsonNode) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(jsonNode);
|
||||
} catch (Exception e) {
|
||||
return "{}";
|
||||
}
|
||||
}
|
||||
|
||||
private void sleep(int attempt, String operation, Exception e) {
|
||||
log.warn("Google Play API retry, operation={}, attempt={}", operation, attempt, e);
|
||||
try {
|
||||
Thread.sleep(RETRY_INTERVAL_MILLIS * attempt);
|
||||
} catch (InterruptedException interruptedException) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new GooglePlayApiException("Google Play 请求被中断", interruptedException);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
public class GooglePlayApiException extends RuntimeException {
|
||||
|
||||
private final int statusCode;
|
||||
|
||||
private final String responseBody;
|
||||
|
||||
public GooglePlayApiException(int statusCode, String responseBody) {
|
||||
super("Google Play API request failed, status=" + statusCode);
|
||||
this.statusCode = statusCode;
|
||||
this.responseBody = responseBody;
|
||||
}
|
||||
|
||||
public GooglePlayApiException(String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
this.statusCode = -1;
|
||||
this.responseBody = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
public final class GooglePlayConstants {
|
||||
|
||||
public static final String PRODUCT_TYPE_SUBSCRIPTION = "SUBSCRIPTION";
|
||||
public static final String PRODUCT_TYPE_ONE_TIME = "ONE_TIME";
|
||||
|
||||
public static final String STATE_PENDING = "PENDING";
|
||||
public static final String STATE_ACTIVE = "ACTIVE";
|
||||
public static final String STATE_CANCELED = "CANCELED";
|
||||
public static final String STATE_IN_GRACE_PERIOD = "IN_GRACE_PERIOD";
|
||||
public static final String STATE_ON_HOLD = "ON_HOLD";
|
||||
public static final String STATE_PAUSED = "PAUSED";
|
||||
public static final String STATE_EXPIRED = "EXPIRED";
|
||||
public static final String STATE_REVOKED = "REVOKED";
|
||||
public static final String STATE_REFUNDED = "REFUNDED";
|
||||
public static final String STATE_PENDING_PURCHASE_CANCELED = "PENDING_PURCHASE_CANCELED";
|
||||
public static final String STATE_UNKNOWN = "UNKNOWN";
|
||||
|
||||
public static final String ACK_PENDING = "PENDING";
|
||||
public static final String ACK_ACKNOWLEDGED = "ACKNOWLEDGED";
|
||||
|
||||
public static final String CONSUMPTION_PENDING = "PENDING";
|
||||
public static final String CONSUMPTION_CONSUMED = "CONSUMED";
|
||||
public static final String CONSUMPTION_NOT_APPLICABLE = "NOT_APPLICABLE";
|
||||
|
||||
public static final String DELIVERY_PENDING = "PENDING";
|
||||
public static final String DELIVERY_PROCESSING = "PROCESSING";
|
||||
public static final String DELIVERY_DELIVERED = "DELIVERED";
|
||||
public static final String DELIVERY_REVOKED = "REVOKED";
|
||||
public static final String DELIVERY_NOT_REQUIRED = "NOT_REQUIRED";
|
||||
public static final String DELIVERY_MANUAL_REVIEW = "MANUAL_REVIEW";
|
||||
|
||||
public static final String ENTITLEMENT_VIP_SUBSCRIPTION = "VIP_SUBSCRIPTION";
|
||||
public static final String ENTITLEMENT_VIP_ONE_TIME = "VIP_ONE_TIME";
|
||||
public static final String ENTITLEMENT_WALLET_TOP_UP = "WALLET_TOP_UP";
|
||||
public static final String ENTITLEMENT_NON_CONSUMABLE = "NON_CONSUMABLE";
|
||||
|
||||
public static final String EVENT_RECEIVED = "RECEIVED";
|
||||
public static final String EVENT_PROCESSED = "PROCESSED";
|
||||
public static final String EVENT_FAILED = "FAILED";
|
||||
public static final String EVENT_IGNORED = "IGNORED";
|
||||
|
||||
public static final String EVENT_TYPE_SUBSCRIPTION = "SUBSCRIPTION";
|
||||
public static final String EVENT_TYPE_ONE_TIME = "ONE_TIME";
|
||||
public static final String EVENT_TYPE_VOIDED = "VOIDED";
|
||||
public static final String EVENT_TYPE_TEST = "TEST";
|
||||
|
||||
public static final String PAYMENT_METHOD_GOOGLE_PLAY = "GOOGLE_PLAY";
|
||||
|
||||
private GooglePlayConstants() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||
import com.yolo.keyborad.mapper.GooglePlayUserEntitlementMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayEntitlementApplier {
|
||||
|
||||
private final GooglePlayUserEntitlementMapper entitlementMapper;
|
||||
private final GooglePlayVipBenefitService vipBenefitService;
|
||||
private final GooglePlayWalletBenefitService walletBenefitService;
|
||||
private final GooglePlayOrderDeliveryGuard orderDeliveryGuard;
|
||||
|
||||
public GooglePlayUserEntitlement apply(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order) {
|
||||
String benefitType = resolveBenefitType(product, snapshot);
|
||||
String entitlementKey = resolveEntitlementKey(benefitType, product.getProductId());
|
||||
boolean grantOwned = orderDeliveryGuard.prepareGrant(benefitType, snapshot, order);
|
||||
GooglePlayUserEntitlement entitlement = loadEntitlement(snapshot.getPurchaseToken(), entitlementKey);
|
||||
if (entitlement == null) {
|
||||
entitlement = new GooglePlayUserEntitlement();
|
||||
entitlement.setCreatedAt(new Date());
|
||||
}
|
||||
fillCommonFields(entitlement, userId, product, snapshot, order, benefitType, entitlementKey);
|
||||
switch (benefitType) {
|
||||
case GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION -> applySubscriptionVip(userId, product, snapshot, order, entitlement);
|
||||
case GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME ->
|
||||
applyOneTimeVip(userId, product, snapshot, order, entitlement, grantOwned);
|
||||
case GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP ->
|
||||
applyWalletTopUp(userId, product, snapshot, order, entitlement, grantOwned);
|
||||
default -> applyNonConsumable(snapshot, order, entitlement, grantOwned);
|
||||
}
|
||||
saveEntitlement(entitlement);
|
||||
return entitlement;
|
||||
}
|
||||
|
||||
private void fillCommonFields(GooglePlayUserEntitlement entitlement,
|
||||
Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
String benefitType,
|
||||
String entitlementKey) {
|
||||
Date now = new Date();
|
||||
entitlement.setUserId(userId);
|
||||
entitlement.setEntitlementKey(entitlementKey);
|
||||
entitlement.setProductId(product.getProductId());
|
||||
entitlement.setProductType(snapshot.getProductType());
|
||||
entitlement.setSourcePurchaseToken(snapshot.getPurchaseToken());
|
||||
entitlement.setCurrentOrderKey(order.getOrderKey());
|
||||
entitlement.setBenefitType(benefitType);
|
||||
entitlement.setState(snapshot.getState());
|
||||
entitlement.setUpdatedAt(now);
|
||||
if (entitlement.getQuantity() == null) {
|
||||
entitlement.setQuantity(BigDecimal.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
private void applySubscriptionVip(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement) {
|
||||
Date expiryTime = snapshot.getExpiryTime();
|
||||
if (snapshot.getAccessGranted() && expiryTime == null) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_STATE_INVALID, "订阅缺少 expiryTime");
|
||||
}
|
||||
entitlement.setActive(Boolean.TRUE.equals(snapshot.getAccessGranted()));
|
||||
entitlement.setStartTime(snapshot.getStartTime());
|
||||
entitlement.setEndTime(expiryTime);
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_NOT_REQUIRED);
|
||||
order.setGrantedQuantity(BigDecimal.ZERO);
|
||||
if (Boolean.TRUE.equals(snapshot.getAccessGranted())) {
|
||||
entitlement.setLastGrantedAt(new Date());
|
||||
vipBenefitService.activate(userId, product, expiryTime);
|
||||
return;
|
||||
}
|
||||
entitlement.setLastRevokedAt(new Date());
|
||||
vipBenefitService.deactivate(userId, expiryTime);
|
||||
}
|
||||
|
||||
private void applyOneTimeVip(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
boolean grantOwned) {
|
||||
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||
grantOneTimeVip(userId, product, order, entitlement, grantOwned);
|
||||
return;
|
||||
}
|
||||
revokeVipEntitlement(userId, order, entitlement);
|
||||
}
|
||||
|
||||
private void applyWalletTopUp(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
boolean grantOwned) {
|
||||
BigDecimal amount = resolveWalletAmount(product);
|
||||
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
|
||||
throw new BusinessException(ErrorCode.PRODUCT_QUOTA_NOT_SET);
|
||||
}
|
||||
if (GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||
grantWalletTopUp(userId, product, order, entitlement, amount, grantOwned);
|
||||
return;
|
||||
}
|
||||
revokeWalletTopUp(userId, order, entitlement, amount);
|
||||
}
|
||||
|
||||
private void applyNonConsumable(GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
boolean grantOwned) {
|
||||
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
||||
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
|
||||
if (!grantOwned && active) {
|
||||
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||
return;
|
||||
}
|
||||
entitlement.setActive(active);
|
||||
entitlement.setStartTime(snapshot.getStartTime());
|
||||
entitlement.setEndTime(snapshot.getExpiryTime());
|
||||
entitlement.setQuantity(BigDecimal.ONE);
|
||||
order.setGrantedQuantity(BigDecimal.ONE);
|
||||
order.setDeliveryStatus(active ? GooglePlayConstants.DELIVERY_DELIVERED : GooglePlayConstants.DELIVERY_REVOKED);
|
||||
if (active) {
|
||||
entitlement.setLastGrantedAt(new Date());
|
||||
return;
|
||||
}
|
||||
entitlement.setLastRevokedAt(new Date());
|
||||
}
|
||||
|
||||
private void grantOneTimeVip(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
boolean grantOwned) {
|
||||
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||
return;
|
||||
}
|
||||
Date expiry = resolveOneTimeVipExpiry(product);
|
||||
vipBenefitService.activate(userId, product, expiry);
|
||||
entitlement.setActive(true);
|
||||
entitlement.setQuantity(BigDecimal.ONE);
|
||||
entitlement.setStartTime(new Date());
|
||||
entitlement.setEndTime(expiry);
|
||||
entitlement.setLastGrantedAt(new Date());
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED);
|
||||
order.setGrantedQuantity(BigDecimal.ONE);
|
||||
}
|
||||
|
||||
private void revokeVipEntitlement(Long userId,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement) {
|
||||
entitlement.setActive(false);
|
||||
entitlement.setLastRevokedAt(new Date());
|
||||
if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||
return;
|
||||
}
|
||||
vipBenefitService.deactivate(userId, entitlement.getEndTime());
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||
}
|
||||
|
||||
private void grantWalletTopUp(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
BigDecimal amount,
|
||||
boolean grantOwned) {
|
||||
if (!grantOwned || GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||
entitlement.setActive(GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus()));
|
||||
return;
|
||||
}
|
||||
walletBenefitService.grant(userId, order.getId(), product.getName(), amount);
|
||||
entitlement.setActive(true);
|
||||
entitlement.setQuantity(amount);
|
||||
entitlement.setStartTime(new Date());
|
||||
entitlement.setLastGrantedAt(new Date());
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_DELIVERED);
|
||||
order.setGrantedQuantity(amount);
|
||||
}
|
||||
|
||||
private void revokeWalletTopUp(Long userId,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
BigDecimal amount) {
|
||||
entitlement.setActive(false);
|
||||
entitlement.setLastRevokedAt(new Date());
|
||||
if (!GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||
return;
|
||||
}
|
||||
boolean revoked = walletBenefitService.revoke(userId, order.getId(), amount);
|
||||
if (!revoked) {
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_MANUAL_REVIEW);
|
||||
return;
|
||||
}
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_REVOKED);
|
||||
}
|
||||
|
||||
private Date resolveOneTimeVipExpiry(KeyboardProductItems product) {
|
||||
if (product.getDurationDays() == null || product.getDurationDays() <= 0) {
|
||||
return null;
|
||||
}
|
||||
long millis = (long) product.getDurationDays() * 24 * 60 * 60 * 1000;
|
||||
return new Date(System.currentTimeMillis() + millis);
|
||||
}
|
||||
|
||||
private String resolveBenefitType(KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot) {
|
||||
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||
return GooglePlayConstants.ENTITLEMENT_VIP_SUBSCRIPTION;
|
||||
}
|
||||
if (looksLikeVipProduct(product)) {
|
||||
return GooglePlayConstants.ENTITLEMENT_VIP_ONE_TIME;
|
||||
}
|
||||
if (looksLikeWalletProduct(product)) {
|
||||
return GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP;
|
||||
}
|
||||
return GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE;
|
||||
}
|
||||
|
||||
private boolean looksLikeVipProduct(KeyboardProductItems product) {
|
||||
String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase();
|
||||
return product.getDurationDays() != null && product.getDurationDays() > 0
|
||||
|| unit.contains("vip")
|
||||
|| unit.contains("member")
|
||||
|| unit.contains("会员");
|
||||
}
|
||||
|
||||
private boolean looksLikeWalletProduct(KeyboardProductItems product) {
|
||||
String unit = product.getUnit() == null ? "" : product.getUnit().toLowerCase();
|
||||
return unit.contains("coin")
|
||||
|| unit.contains("quota")
|
||||
|| unit.contains("credit")
|
||||
|| unit.contains("金币")
|
||||
|| unit.contains("次数")
|
||||
|| resolveWalletAmount(product).compareTo(BigDecimal.ZERO) > 0;
|
||||
}
|
||||
|
||||
private BigDecimal resolveWalletAmount(KeyboardProductItems product) {
|
||||
BigDecimal fromName = parseNumber(product.getName());
|
||||
if (fromName != null) {
|
||||
return fromName;
|
||||
}
|
||||
if (product.getDurationValue() != null) {
|
||||
return BigDecimal.valueOf(product.getDurationValue());
|
||||
}
|
||||
return BigDecimal.ZERO;
|
||||
}
|
||||
|
||||
private BigDecimal parseNumber(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(raw.replaceAll("[^\\d.]", ""));
|
||||
} catch (Exception e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveEntitlementKey(String benefitType, String productId) {
|
||||
if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) {
|
||||
return "PRODUCT:" + productId;
|
||||
}
|
||||
if (GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(benefitType)) {
|
||||
return "WALLET";
|
||||
}
|
||||
return "VIP";
|
||||
}
|
||||
|
||||
private GooglePlayUserEntitlement loadEntitlement(String purchaseToken, String entitlementKey) {
|
||||
return entitlementMapper.selectOne(com.baomidou.mybatisplus.core.toolkit.Wrappers.<GooglePlayUserEntitlement>lambdaQuery()
|
||||
.eq(GooglePlayUserEntitlement::getSourcePurchaseToken, purchaseToken)
|
||||
.eq(GooglePlayUserEntitlement::getEntitlementKey, entitlementKey)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private void saveEntitlement(GooglePlayUserEntitlement entitlement) {
|
||||
if (entitlement.getId() == null) {
|
||||
entitlementMapper.insert(entitlement);
|
||||
return;
|
||||
}
|
||||
entitlementMapper.updateById(entitlement);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayNotificationSupport {
|
||||
|
||||
private static final int VOIDED_PRODUCT_TYPE_SUBSCRIPTION = 1;
|
||||
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GooglePlayPubSubPushRequest.DeveloperNotification decode(GooglePlayPubSubPushRequest pushRequest) {
|
||||
try {
|
||||
byte[] decoded = Base64.getDecoder().decode(pushRequest.getMessage().getData());
|
||||
return objectMapper.readValue(decoded, GooglePlayPubSubPushRequest.DeveloperNotification.class);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_RTDN_PAYLOAD_INVALID, "RTDN payload 解析失败");
|
||||
}
|
||||
}
|
||||
|
||||
public GooglePlaySyncCommand buildCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||
String packageName) {
|
||||
if (notification.getSubscriptionNotification() != null) {
|
||||
return buildSubscriptionCommand(pushRequest, notification, packageName);
|
||||
}
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return buildOneTimeCommand(pushRequest, notification, packageName);
|
||||
}
|
||||
return buildVoidedCommand(pushRequest, notification, packageName);
|
||||
}
|
||||
|
||||
public String resolveEventType(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
if (notification.getSubscriptionNotification() != null) {
|
||||
return GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION;
|
||||
}
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return GooglePlayConstants.EVENT_TYPE_ONE_TIME;
|
||||
}
|
||||
if (notification.getVoidedPurchaseNotification() != null) {
|
||||
return GooglePlayConstants.EVENT_TYPE_VOIDED;
|
||||
}
|
||||
return GooglePlayConstants.EVENT_TYPE_TEST;
|
||||
}
|
||||
|
||||
public Integer resolveNotificationType(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
if (notification.getSubscriptionNotification() != null) {
|
||||
return notification.getSubscriptionNotification().getNotificationType();
|
||||
}
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return notification.getOneTimeProductNotification().getNotificationType();
|
||||
}
|
||||
return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getRefundType();
|
||||
}
|
||||
|
||||
public String resolveNotificationName(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
if (notification.getSubscriptionNotification() != null) {
|
||||
return resolveSubscriptionName(notification.getSubscriptionNotification().getNotificationType());
|
||||
}
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return resolveOneTimeName(notification.getOneTimeProductNotification().getNotificationType());
|
||||
}
|
||||
return notification.getVoidedPurchaseNotification() == null ? "TEST_NOTIFICATION" : "VOIDED_PURCHASE";
|
||||
}
|
||||
|
||||
public String resolvePurchaseToken(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
if (notification.getSubscriptionNotification() != null) {
|
||||
return notification.getSubscriptionNotification().getPurchaseToken();
|
||||
}
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return notification.getOneTimeProductNotification().getPurchaseToken();
|
||||
}
|
||||
return notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getPurchaseToken();
|
||||
}
|
||||
|
||||
public String resolveProductId(GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
if (notification.getOneTimeProductNotification() != null) {
|
||||
return notification.getOneTimeProductNotification().getSku();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public String normalizeVoidedProductType(Integer productType) {
|
||||
return VOIDED_PRODUCT_TYPE_SUBSCRIPTION == productType
|
||||
? GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION
|
||||
: GooglePlayConstants.PRODUCT_TYPE_ONE_TIME;
|
||||
}
|
||||
|
||||
public Date resolveEventTime(String eventTimeMillis) {
|
||||
if (eventTimeMillis == null || eventTimeMillis.isBlank()) {
|
||||
return new Date();
|
||||
}
|
||||
return new Date(Long.parseLong(eventTimeMillis));
|
||||
}
|
||||
|
||||
private GooglePlaySyncCommand buildSubscriptionCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||
String packageName) {
|
||||
int type = notification.getSubscriptionNotification().getNotificationType();
|
||||
return GooglePlaySyncCommand.builder()
|
||||
.packageName(packageName)
|
||||
.productType(GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION)
|
||||
.purchaseToken(notification.getSubscriptionNotification().getPurchaseToken())
|
||||
.source("RTDN")
|
||||
.eventType(GooglePlayConstants.EVENT_TYPE_SUBSCRIPTION)
|
||||
.notificationType(type)
|
||||
.notificationName(resolveSubscriptionName(type))
|
||||
.messageId(pushRequest.getMessage().getMessageId())
|
||||
.subscriptionName(pushRequest.getSubscription())
|
||||
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private GooglePlaySyncCommand buildOneTimeCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||
String packageName) {
|
||||
int type = notification.getOneTimeProductNotification().getNotificationType();
|
||||
return GooglePlaySyncCommand.builder()
|
||||
.packageName(packageName)
|
||||
.productId(notification.getOneTimeProductNotification().getSku())
|
||||
.productType(GooglePlayConstants.PRODUCT_TYPE_ONE_TIME)
|
||||
.purchaseToken(notification.getOneTimeProductNotification().getPurchaseToken())
|
||||
.source("RTDN")
|
||||
.eventType(GooglePlayConstants.EVENT_TYPE_ONE_TIME)
|
||||
.notificationType(type)
|
||||
.notificationName(resolveOneTimeName(type))
|
||||
.messageId(pushRequest.getMessage().getMessageId())
|
||||
.subscriptionName(pushRequest.getSubscription())
|
||||
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private GooglePlaySyncCommand buildVoidedCommand(GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification,
|
||||
String packageName) {
|
||||
GooglePlayPubSubPushRequest.VoidedPurchaseNotification voided = notification.getVoidedPurchaseNotification();
|
||||
return GooglePlaySyncCommand.builder()
|
||||
.packageName(packageName)
|
||||
.productType(normalizeVoidedProductType(voided.getProductType()))
|
||||
.purchaseToken(voided.getPurchaseToken())
|
||||
.source("RTDN")
|
||||
.eventType(GooglePlayConstants.EVENT_TYPE_VOIDED)
|
||||
.notificationType(voided.getRefundType())
|
||||
.notificationName("VOIDED_PURCHASE")
|
||||
.messageId(pushRequest.getMessage().getMessageId())
|
||||
.subscriptionName(pushRequest.getSubscription())
|
||||
.eventTime(resolveEventTime(notification.getEventTimeMillis()))
|
||||
.build();
|
||||
}
|
||||
|
||||
private String resolveSubscriptionName(Integer type) {
|
||||
return switch (type) {
|
||||
case 1 -> "SUBSCRIPTION_RECOVERED";
|
||||
case 2 -> "SUBSCRIPTION_RENEWED";
|
||||
case 3 -> "SUBSCRIPTION_CANCELED";
|
||||
case 4 -> "SUBSCRIPTION_PURCHASED";
|
||||
case 5 -> "SUBSCRIPTION_ON_HOLD";
|
||||
case 6 -> "SUBSCRIPTION_IN_GRACE_PERIOD";
|
||||
case 7 -> "SUBSCRIPTION_RESTARTED";
|
||||
case 9 -> "SUBSCRIPTION_DEFERRED";
|
||||
case 10 -> "SUBSCRIPTION_PAUSED";
|
||||
case 11 -> "SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED";
|
||||
case 12 -> "SUBSCRIPTION_REVOKED";
|
||||
case 13 -> "SUBSCRIPTION_EXPIRED";
|
||||
case 17 -> "SUBSCRIPTION_ITEMS_CHANGED";
|
||||
case 18 -> "SUBSCRIPTION_CANCELLATION_SCHEDULED";
|
||||
case 19 -> "SUBSCRIPTION_PRICE_CHANGE_UPDATED";
|
||||
case 20 -> "SUBSCRIPTION_PENDING_PURCHASE_CANCELED";
|
||||
case 22 -> "SUBSCRIPTION_PRICE_STEP_UP_CONSENT_UPDATED";
|
||||
default -> "SUBSCRIPTION_UNKNOWN";
|
||||
};
|
||||
}
|
||||
|
||||
private String resolveOneTimeName(Integer type) {
|
||||
return switch (type) {
|
||||
case 1 -> "ONE_TIME_PRODUCT_PURCHASED";
|
||||
case 2 -> "ONE_TIME_PRODUCT_CANCELED";
|
||||
default -> "ONE_TIME_UNKNOWN";
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||
import com.yolo.keyborad.mapper.GooglePlayOrderMapper;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayOrderDeliveryGuard {
|
||||
|
||||
private final GooglePlayOrderMapper orderMapper;
|
||||
|
||||
/**
|
||||
* 一次性商品在真正发权益前先抢占处理资格,避免并发请求重复发货。
|
||||
*/
|
||||
public boolean prepareGrant(String benefitType, GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) {
|
||||
if (!requiresGrantGuard(benefitType, snapshot) || order.getId() == null) {
|
||||
order.setDeliveryOwnershipGranted(true);
|
||||
return true;
|
||||
}
|
||||
if (GooglePlayConstants.DELIVERY_DELIVERED.equals(order.getDeliveryStatus())) {
|
||||
order.setDeliveryOwnershipGranted(false);
|
||||
return false;
|
||||
}
|
||||
Date now = new Date();
|
||||
int updated = orderMapper.updateDeliveryStatusIfMatch(
|
||||
order.getId(),
|
||||
GooglePlayConstants.DELIVERY_PENDING,
|
||||
GooglePlayConstants.DELIVERY_PROCESSING,
|
||||
now
|
||||
);
|
||||
if (updated == 1) {
|
||||
order.setDeliveryStatus(GooglePlayConstants.DELIVERY_PROCESSING);
|
||||
order.setDeliveryOwnershipGranted(true);
|
||||
order.setUpdatedAt(now);
|
||||
return true;
|
||||
}
|
||||
refreshDeliveryState(order);
|
||||
order.setDeliveryOwnershipGranted(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
private boolean requiresGrantGuard(String benefitType, GooglePlayPurchaseSnapshot snapshot) {
|
||||
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
|
||||
return false;
|
||||
}
|
||||
if (GooglePlayConstants.ENTITLEMENT_NON_CONSUMABLE.equals(benefitType)) {
|
||||
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())
|
||||
|| GooglePlayConstants.STATE_CANCELED.equals(snapshot.getState());
|
||||
}
|
||||
return GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
||||
}
|
||||
|
||||
private void refreshDeliveryState(GooglePlayOrder order) {
|
||||
GooglePlayOrder latestOrder = orderMapper.selectById(order.getId());
|
||||
if (latestOrder == null) {
|
||||
return;
|
||||
}
|
||||
order.setDeliveryStatus(latestOrder.getDeliveryStatus());
|
||||
order.setGrantedQuantity(latestOrder.getGrantedQuantity());
|
||||
order.setUpdatedAt(latestOrder.getUpdatedAt());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayPubSubAuthService {
|
||||
|
||||
private final GooglePlayProperties properties;
|
||||
private final GooglePlayApiClient apiClient;
|
||||
|
||||
public void verify(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest) {
|
||||
// verifyTopic(request);
|
||||
verifySubscription(pushRequest);
|
||||
if (!properties.isValidatePubsubJwt()) {
|
||||
return;
|
||||
}
|
||||
String bearerToken = resolveBearerToken(request);
|
||||
JsonNode tokenInfo = apiClient.verifyPubSubJwt(bearerToken);
|
||||
verifyAudience(tokenInfo);
|
||||
verifyEmail(tokenInfo);
|
||||
verifyIssuer(tokenInfo);
|
||||
}
|
||||
|
||||
private void verifyTopic(HttpServletRequest request) {
|
||||
String expectedTopic = properties.getPubsub().getExpectedTopic();
|
||||
if (expectedTopic == null || expectedTopic.isBlank()) {
|
||||
return;
|
||||
}
|
||||
String currentTopic = request.getHeader("projects/keyboard-490601/topics/keyboard_topic");
|
||||
if (!expectedTopic.equals(currentTopic)) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub topic 不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifySubscription(GooglePlayPubSubPushRequest pushRequest) {
|
||||
String expectedSubscription = properties.getPubsub().getExpectedSubscription();
|
||||
if (expectedSubscription == null || expectedSubscription.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (!expectedSubscription.equals(pushRequest.getSubscription())) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub subscription 不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveBearerToken(HttpServletRequest request) {
|
||||
String authorization = request.getHeader("Authorization");
|
||||
if (authorization == null || !authorization.startsWith("Bearer ")) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "缺少 Pub/Sub Bearer Token");
|
||||
}
|
||||
return authorization.substring("Bearer ".length());
|
||||
}
|
||||
|
||||
private void verifyAudience(JsonNode tokenInfo) {
|
||||
String expectedAudience = properties.getPubsub().getAudience();
|
||||
if (expectedAudience == null || expectedAudience.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (!expectedAudience.equalsIgnoreCase(tokenInfo.path("aud").asText())) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub audience 不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyEmail(JsonNode tokenInfo) {
|
||||
if (!tokenInfo.path("email_verified").asBoolean(false)) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub 邮箱未验证");
|
||||
}
|
||||
String expectedEmail = properties.getPubsub().getServiceAccountEmail();
|
||||
if (expectedEmail == null || expectedEmail.isBlank()) {
|
||||
return;
|
||||
}
|
||||
if (!expectedEmail.equalsIgnoreCase(tokenInfo.path("email").asText())) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub service account 不匹配");
|
||||
}
|
||||
}
|
||||
|
||||
private void verifyIssuer(JsonNode tokenInfo) {
|
||||
String issuer = tokenInfo.path("iss").asText();
|
||||
boolean valid = "https://accounts.google.com".equals(issuer) || "accounts.google.com".equals(issuer);
|
||||
if (!valid) {
|
||||
throw new BusinessException(ErrorCode.GOOGLE_PLAY_WEBHOOK_UNAUTHORIZED, "Pub/Sub issuer 非法");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||
import com.yolo.keyborad.model.entity.KeyboardUserPurchaseRecords;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||
import com.yolo.keyborad.service.KeyboardUserPurchaseRecordsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
import java.util.Objects;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayPurchaseRecordService {
|
||||
|
||||
private static final String PURCHASE_STATUS_PAID = "PAID";
|
||||
|
||||
private final KeyboardUserPurchaseRecordsService purchaseRecordsService;
|
||||
|
||||
public void recordSuccess(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement) {
|
||||
if (!shouldRecord(userId, order, entitlement)) {
|
||||
return;
|
||||
}
|
||||
String transactionId = resolveTransactionId(order);
|
||||
if (alreadyRecorded(transactionId)) {
|
||||
return;
|
||||
}
|
||||
purchaseRecordsService.save(buildRecord(userId, product, snapshot, order, transactionId));
|
||||
}
|
||||
|
||||
private boolean shouldRecord(Long userId,
|
||||
GooglePlayOrder order,
|
||||
GooglePlayUserEntitlement entitlement) {
|
||||
return userId != null
|
||||
&& order != null
|
||||
&& entitlement != null
|
||||
&& Boolean.TRUE.equals(entitlement.getActive())
|
||||
&& !GooglePlayConstants.DELIVERY_REVOKED.equals(order.getDeliveryStatus())
|
||||
&& !GooglePlayConstants.DELIVERY_MANUAL_REVIEW.equals(order.getDeliveryStatus());
|
||||
}
|
||||
|
||||
private boolean alreadyRecorded(String transactionId) {
|
||||
return purchaseRecordsService.lambdaQuery()
|
||||
.eq(KeyboardUserPurchaseRecords::getTransactionId, transactionId)
|
||||
.eq(KeyboardUserPurchaseRecords::getPaymentMethod, GooglePlayConstants.PAYMENT_METHOD_GOOGLE_PLAY)
|
||||
.exists();
|
||||
}
|
||||
|
||||
private KeyboardUserPurchaseRecords buildRecord(Long userId,
|
||||
KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order,
|
||||
String transactionId) {
|
||||
KeyboardUserPurchaseRecords record = new KeyboardUserPurchaseRecords();
|
||||
record.setUserId(Math.toIntExact(userId));
|
||||
record.setProductId(product.getProductId());
|
||||
record.setPurchaseQuantity(resolvePurchaseQuantity(product, snapshot, order));
|
||||
record.setPrice(product.getPrice());
|
||||
record.setCurrency(product.getCurrency());
|
||||
record.setPurchaseType(resolvePurchaseType(product, snapshot));
|
||||
Date purchaseTime = resolvePurchaseTime(snapshot, order);
|
||||
record.setPurchaseTime(purchaseTime);
|
||||
record.setStatus(PURCHASE_STATUS_PAID);
|
||||
record.setPaymentMethod(GooglePlayConstants.PAYMENT_METHOD_GOOGLE_PLAY);
|
||||
record.setTransactionId(transactionId);
|
||||
record.setOriginalTransactionId(order.getPurchaseToken());
|
||||
record.setProductIds(new String[]{product.getProductId()});
|
||||
record.setPurchaseDate(purchaseTime);
|
||||
record.setExpiresDate(snapshot.getExpiryTime());
|
||||
return record;
|
||||
}
|
||||
|
||||
private Integer resolvePurchaseQuantity(KeyboardProductItems product,
|
||||
GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayOrder order) {
|
||||
Integer configured = product.getDurationValue();
|
||||
if (configured != null) {
|
||||
return configured;
|
||||
}
|
||||
Integer parsed = parseInteger(product.getName());
|
||||
if (parsed != null) {
|
||||
return parsed;
|
||||
}
|
||||
if (snapshot.getQuantity() != null) {
|
||||
return snapshot.getQuantity();
|
||||
}
|
||||
return Objects.requireNonNullElse(order.getQuantity(), 1);
|
||||
}
|
||||
|
||||
private Integer parseInteger(String raw) {
|
||||
if (raw == null || raw.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
String digits = raw.replaceAll("[^\\d]", "");
|
||||
if (digits.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
return Integer.parseInt(digits);
|
||||
}
|
||||
|
||||
private String resolvePurchaseType(KeyboardProductItems product, GooglePlayPurchaseSnapshot snapshot) {
|
||||
if (product.getType() != null && !product.getType().isBlank()) {
|
||||
return product.getType();
|
||||
}
|
||||
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||
return "subscription";
|
||||
}
|
||||
return "in-app-purchase";
|
||||
}
|
||||
|
||||
private Date resolvePurchaseTime(GooglePlayPurchaseSnapshot snapshot, GooglePlayOrder order) {
|
||||
if (snapshot.getStartTime() != null) {
|
||||
return snapshot.getStartTime();
|
||||
}
|
||||
if (order.getLastEventTime() != null) {
|
||||
return order.getLastEventTime();
|
||||
}
|
||||
if (snapshot.getLastSyncedAt() != null) {
|
||||
return snapshot.getLastSyncedAt();
|
||||
}
|
||||
return new Date();
|
||||
}
|
||||
|
||||
private String resolveTransactionId(GooglePlayOrder order) {
|
||||
if (order.getGoogleOrderId() != null && !order.getGoogleOrderId().isBlank()) {
|
||||
return order.getGoogleOrderId();
|
||||
}
|
||||
return order.getOrderKey();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.mapper.GooglePlayRtdnEventMapper;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayRtdnEventService {
|
||||
|
||||
private final GooglePlayRtdnEventMapper rtdnEventMapper;
|
||||
private final GooglePlayNotificationSupport notificationSupport;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public GooglePlayRtdnEvent upsertEvent(GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
String messageId = pushRequest.getMessage().getMessageId();
|
||||
GooglePlayRtdnEvent event = rtdnEventMapper.selectOne(Wrappers.<GooglePlayRtdnEvent>lambdaQuery()
|
||||
.eq(GooglePlayRtdnEvent::getMessageId, messageId)
|
||||
.last("LIMIT 1"));
|
||||
if (event == null) {
|
||||
event = new GooglePlayRtdnEvent();
|
||||
event.setMessageId(messageId);
|
||||
event.setRetryCount(0);
|
||||
event.setCreatedAt(new Date());
|
||||
} else if (GooglePlayConstants.EVENT_PROCESSED.equals(event.getStatus())) {
|
||||
return event;
|
||||
} else {
|
||||
event.setRetryCount(event.getRetryCount() == null ? 1 : event.getRetryCount() + 1);
|
||||
}
|
||||
fillEvent(event, pushRequest, notification);
|
||||
saveEvent(event);
|
||||
return event;
|
||||
}
|
||||
|
||||
public void markProcessed(GooglePlayRtdnEvent event) {
|
||||
mark(event, GooglePlayConstants.EVENT_PROCESSED, null, true);
|
||||
}
|
||||
|
||||
public void markIgnored(GooglePlayRtdnEvent event) {
|
||||
mark(event, GooglePlayConstants.EVENT_IGNORED, null, true);
|
||||
}
|
||||
|
||||
public void markFailed(GooglePlayRtdnEvent event, Exception e) {
|
||||
mark(event, GooglePlayConstants.EVENT_FAILED, e.getMessage(), false);
|
||||
}
|
||||
|
||||
private void fillEvent(GooglePlayRtdnEvent event,
|
||||
GooglePlayPubSubPushRequest pushRequest,
|
||||
GooglePlayPubSubPushRequest.DeveloperNotification notification) {
|
||||
event.setSubscriptionName(pushRequest.getSubscription());
|
||||
event.setPackageName(notification.getPackageName());
|
||||
event.setEventType(notificationSupport.resolveEventType(notification));
|
||||
event.setNotificationType(notificationSupport.resolveNotificationType(notification));
|
||||
event.setNotificationName(notificationSupport.resolveNotificationName(notification));
|
||||
event.setPurchaseToken(notificationSupport.resolvePurchaseToken(notification));
|
||||
event.setProductId(notificationSupport.resolveProductId(notification));
|
||||
event.setOrderId(notification.getVoidedPurchaseNotification() == null ? null : notification.getVoidedPurchaseNotification().getOrderId());
|
||||
event.setEventTime(notificationSupport.resolveEventTime(notification.getEventTimeMillis()));
|
||||
event.setStatus(GooglePlayConstants.EVENT_RECEIVED);
|
||||
event.setRawEnvelope(writeJson(pushRequest));
|
||||
event.setRawPayload(writeJson(notification));
|
||||
event.setErrorMessage(null);
|
||||
event.setProcessedAt(null);
|
||||
event.setUpdatedAt(new Date());
|
||||
}
|
||||
|
||||
private void mark(GooglePlayRtdnEvent event, String status, String errorMessage, boolean processed) {
|
||||
event.setStatus(status);
|
||||
event.setErrorMessage(errorMessage);
|
||||
event.setProcessedAt(processed ? new Date() : null);
|
||||
event.setUpdatedAt(new Date());
|
||||
saveEvent(event);
|
||||
}
|
||||
|
||||
private void saveEvent(GooglePlayRtdnEvent event) {
|
||||
if (event.getId() == null) {
|
||||
rtdnEventMapper.insert(event);
|
||||
return;
|
||||
}
|
||||
rtdnEventMapper.updateById(event);
|
||||
}
|
||||
|
||||
private String writeJson(Object value) {
|
||||
try {
|
||||
return objectMapper.writeValueAsString(value);
|
||||
} catch (Exception e) {
|
||||
throw new BusinessException(ErrorCode.OPERATION_ERROR, "JSON 序列化失败");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.yolo.keyborad.config.GooglePlayProperties;
|
||||
import io.jsonwebtoken.Jwts;
|
||||
import io.jsonwebtoken.SignatureAlgorithm;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.ResourceLoader;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.PrivateKey;
|
||||
import java.security.spec.PKCS8EncodedKeySpec;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayServiceAccountTokenProvider {
|
||||
|
||||
private static final long ASSERTION_EXPIRE_SECONDS = 3600;
|
||||
private static final long REFRESH_EARLY_SECONDS = 300;
|
||||
|
||||
private final GooglePlayProperties properties;
|
||||
private final ResourceLoader resourceLoader;
|
||||
private final ObjectMapper objectMapper;
|
||||
private final HttpClient googlePlayHttpClient;
|
||||
|
||||
private volatile CachedToken cachedToken;
|
||||
private volatile ServiceAccountCredentials credentials;
|
||||
|
||||
public String getAccessToken() {
|
||||
CachedToken token = cachedToken;
|
||||
if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) {
|
||||
return token.token();
|
||||
}
|
||||
synchronized (this) {
|
||||
token = cachedToken;
|
||||
if (token != null && token.expiresAt().isAfter(Instant.now().plusSeconds(REFRESH_EARLY_SECONDS))) {
|
||||
return token.token();
|
||||
}
|
||||
cachedToken = fetchAccessToken(loadCredentials());
|
||||
return cachedToken.token();
|
||||
}
|
||||
}
|
||||
|
||||
private CachedToken fetchAccessToken(ServiceAccountCredentials account) {
|
||||
try {
|
||||
String assertion = buildAssertion(account);
|
||||
String body = "grant_type=" + encode("urn:ietf:params:oauth:grant-type:jwt-bearer")
|
||||
+ "&assertion=" + encode(assertion);
|
||||
HttpRequest request = HttpRequest.newBuilder(URI.create(properties.getOauthTokenUri()))
|
||||
.header("Content-Type", "application/x-www-form-urlencoded")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
HttpResponse<String> response = googlePlayHttpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||
if (response.statusCode() / 100 != 2) {
|
||||
throw new GooglePlayApiException(response.statusCode(), response.body());
|
||||
}
|
||||
JsonNode jsonNode = objectMapper.readTree(response.body());
|
||||
String accessToken = jsonNode.path("access_token").asText();
|
||||
long expiresIn = jsonNode.path("expires_in").asLong(ASSERTION_EXPIRE_SECONDS);
|
||||
return new CachedToken(accessToken, Instant.now().plusSeconds(expiresIn));
|
||||
} catch (GooglePlayApiException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new GooglePlayApiException("获取 Google Play access token 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private String buildAssertion(ServiceAccountCredentials account) {
|
||||
Instant now = Instant.now();
|
||||
return Jwts.builder()
|
||||
.setHeaderParam("kid", account.privateKeyId())
|
||||
.setIssuer(account.clientEmail())
|
||||
.setAudience(properties.getOauthTokenUri())
|
||||
.claim("scope", properties.getAndroidPublisherScope())
|
||||
.setIssuedAt(Date.from(now))
|
||||
.setExpiration(Date.from(now.plusSeconds(ASSERTION_EXPIRE_SECONDS)))
|
||||
.signWith(account.privateKey(), SignatureAlgorithm.RS256)
|
||||
.compact();
|
||||
}
|
||||
|
||||
private ServiceAccountCredentials loadCredentials() {
|
||||
ServiceAccountCredentials account = credentials;
|
||||
if (account != null) {
|
||||
return account;
|
||||
}
|
||||
synchronized (this) {
|
||||
account = credentials;
|
||||
if (account != null) {
|
||||
return account;
|
||||
}
|
||||
credentials = readCredentials();
|
||||
return credentials;
|
||||
}
|
||||
}
|
||||
|
||||
private ServiceAccountCredentials readCredentials() {
|
||||
if (properties.getServiceAccountKeyPath() == null || properties.getServiceAccountKeyPath().isBlank()) {
|
||||
throw new IllegalStateException("google.play.service-account-key-path 未配置");
|
||||
}
|
||||
try {
|
||||
Resource resource = resourceLoader.getResource(properties.getServiceAccountKeyPath());
|
||||
JsonNode jsonNode = objectMapper.readTree(resource.getInputStream());
|
||||
String clientEmail = jsonNode.path("client_email").asText();
|
||||
String privateKeyId = jsonNode.path("private_key_id").asText();
|
||||
String privateKeyPem = jsonNode.path("private_key").asText();
|
||||
return new ServiceAccountCredentials(clientEmail, privateKeyId, parsePrivateKey(privateKeyPem));
|
||||
} catch (Exception e) {
|
||||
throw new GooglePlayApiException("读取 Google Play service account 失败", e);
|
||||
}
|
||||
}
|
||||
|
||||
private PrivateKey parsePrivateKey(String privateKeyPem) throws Exception {
|
||||
String sanitized = privateKeyPem
|
||||
.replace("-----BEGIN PRIVATE KEY-----", "")
|
||||
.replace("-----END PRIVATE KEY-----", "")
|
||||
.replaceAll("\\s+", "");
|
||||
byte[] decoded = Base64.getDecoder().decode(sanitized);
|
||||
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(decoded);
|
||||
return KeyFactory.getInstance("RSA").generatePrivate(spec);
|
||||
}
|
||||
|
||||
private String encode(String raw) {
|
||||
return URLEncoder.encode(raw, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private record CachedToken(String token, Instant expiresAt) {
|
||||
}
|
||||
|
||||
private record ServiceAccountCredentials(String clientEmail, String privateKeyId, PrivateKey privateKey) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlayPurchaseSnapshot;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlaySyncCommand;
|
||||
import com.yolo.keyborad.googleplay.model.GooglePlaySyncResult;
|
||||
import com.yolo.keyborad.mapper.GooglePlayOrderMapper;
|
||||
import com.yolo.keyborad.mapper.GooglePlayPurchaseTokenMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||
import com.yolo.keyborad.service.KeyboardProductItemsService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.dao.DuplicateKeyException;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayStateService {
|
||||
|
||||
private final GooglePlayOrderMapper orderMapper;
|
||||
private final GooglePlayPurchaseTokenMapper purchaseTokenMapper;
|
||||
private final KeyboardProductItemsService productItemsService;
|
||||
private final GooglePlayEntitlementApplier entitlementApplier;
|
||||
private final GooglePlayPurchaseRecordService purchaseRecordService;
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public GooglePlaySyncResult sync(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||
String productId = (snapshot.getBasePlanId() != null) ?
|
||||
snapshot.getBasePlanId() :
|
||||
snapshot.getPurchaseOptionId();
|
||||
KeyboardProductItems product = loadProduct(productId);
|
||||
GooglePlayOrder order = buildOrder(command, snapshot);
|
||||
GooglePlayPurchaseToken token = buildToken(command, snapshot);
|
||||
persistOrderIfNew(order);
|
||||
GooglePlayUserEntitlement entitlement = null;
|
||||
if (command.getUserId() != null) {
|
||||
entitlement = entitlementApplier.apply(command.getUserId(), product, snapshot, order);
|
||||
purchaseRecordService.recordSuccess(command.getUserId(), product, snapshot, order, entitlement);
|
||||
}
|
||||
// 再次保存以持久化 apply() 中修改的 deliveryStatus/grantedQuantity
|
||||
saveOrder(order);
|
||||
token.setLatestOrderKey(order.getOrderKey());
|
||||
token.setLatestOrderId(order.getGoogleOrderId());
|
||||
saveToken(token);
|
||||
return GooglePlaySyncResult.builder()
|
||||
.order(order)
|
||||
.token(token)
|
||||
.entitlement(entitlement)
|
||||
.acknowledgeRequired(requiresAcknowledge(snapshot, command.getUserId(), order))
|
||||
.consumeRequired(requiresConsume(snapshot, entitlement, command.getUserId(), order))
|
||||
.linkedPurchaseTokenToSync(resolveLinkedToken(snapshot))
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markAcknowledged(String purchaseToken, String orderKey) {
|
||||
updateAckState(purchaseToken, orderKey, GooglePlayConstants.ACK_ACKNOWLEDGED);
|
||||
}
|
||||
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public void markConsumed(String purchaseToken, String orderKey) {
|
||||
updateConsumptionState(purchaseToken, orderKey, GooglePlayConstants.CONSUMPTION_CONSUMED);
|
||||
}
|
||||
|
||||
public GooglePlayPurchaseToken findToken(String purchaseToken) {
|
||||
return purchaseTokenMapper.selectOne(Wrappers.<GooglePlayPurchaseToken>lambdaQuery()
|
||||
.eq(GooglePlayPurchaseToken::getPurchaseToken, purchaseToken)
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
|
||||
private KeyboardProductItems loadProduct(String productId) {
|
||||
KeyboardProductItems product = productItemsService.getProductEntityByProductId(productId);
|
||||
if (product == null) {
|
||||
throw new BusinessException(ErrorCode.PRODUCT_NOT_FOUND, "Google Play 商品不存在: " + productId);
|
||||
}
|
||||
return product;
|
||||
}
|
||||
|
||||
private GooglePlayOrder buildOrder(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||
.eq(GooglePlayOrder::getOrderKey, snapshot.getOrderKey())
|
||||
.last("LIMIT 1"));
|
||||
if (order == null) {
|
||||
order = new GooglePlayOrder();
|
||||
order.setCreatedAt(new Date());
|
||||
order.setDeliveryStatus(defaultDeliveryStatus(snapshot));
|
||||
order.setGrantedQuantity(BigDecimal.ZERO);
|
||||
}
|
||||
order.setUserId(command.getUserId());
|
||||
order.setPackageName(snapshot.getPackageName());
|
||||
order.setProductId(snapshot.getProductId());
|
||||
order.setProductType(snapshot.getProductType());
|
||||
order.setPurchaseToken(snapshot.getPurchaseToken());
|
||||
order.setOrderKey(snapshot.getOrderKey());
|
||||
order.setGoogleOrderId(snapshot.getGoogleOrderId());
|
||||
order.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken());
|
||||
order.setOrderState(snapshot.getState());
|
||||
order.setAcknowledgementState(snapshot.getAcknowledgementState());
|
||||
order.setConsumptionState(snapshot.getConsumptionState());
|
||||
order.setQuantity(snapshot.getQuantity());
|
||||
order.setRefundableQuantity(snapshot.getRefundableQuantity());
|
||||
order.setEntitlementStartTime(snapshot.getStartTime());
|
||||
order.setEntitlementEndTime(snapshot.getExpiryTime());
|
||||
order.setLastEventTime(command.getEventTime());
|
||||
order.setLastSyncedAt(snapshot.getLastSyncedAt());
|
||||
order.setRawResponse(snapshot.getRawResponse());
|
||||
order.setUpdatedAt(new Date());
|
||||
return order;
|
||||
}
|
||||
|
||||
private GooglePlayPurchaseToken buildToken(GooglePlaySyncCommand command, GooglePlayPurchaseSnapshot snapshot) {
|
||||
GooglePlayPurchaseToken token = findToken(snapshot.getPurchaseToken());
|
||||
if (token == null) {
|
||||
token = new GooglePlayPurchaseToken();
|
||||
token.setCreatedAt(new Date());
|
||||
}
|
||||
token.setPurchaseToken(snapshot.getPurchaseToken());
|
||||
token.setLinkedPurchaseToken(snapshot.getLinkedPurchaseToken());
|
||||
token.setUserId(command.getUserId());
|
||||
token.setPackageName(snapshot.getPackageName());
|
||||
token.setProductId(snapshot.getProductId());
|
||||
token.setProductType(snapshot.getProductType());
|
||||
token.setTokenState(snapshot.getState());
|
||||
token.setAcknowledgementState(snapshot.getAcknowledgementState());
|
||||
token.setConsumptionState(snapshot.getConsumptionState());
|
||||
token.setAutoRenewEnabled(snapshot.getAutoRenewEnabled());
|
||||
token.setExternalAccountId(snapshot.getExternalAccountId());
|
||||
token.setExternalProfileId(snapshot.getExternalProfileId());
|
||||
token.setRegionCode(snapshot.getRegionCode());
|
||||
token.setStartTime(snapshot.getStartTime());
|
||||
token.setExpiryTime(snapshot.getExpiryTime());
|
||||
token.setAutoResumeTime(snapshot.getAutoResumeTime());
|
||||
token.setCanceledStateReason(snapshot.getCanceledStateReason());
|
||||
token.setLastEventType(command.getNotificationName());
|
||||
token.setLastEventTime(command.getEventTime());
|
||||
token.setLastSyncedAt(snapshot.getLastSyncedAt());
|
||||
token.setRawResponse(snapshot.getRawResponse());
|
||||
token.setUpdatedAt(new Date());
|
||||
return token;
|
||||
}
|
||||
|
||||
private boolean requiresAcknowledge(GooglePlayPurchaseSnapshot snapshot, Long userId, GooglePlayOrder order) {
|
||||
if (userId == null
|
||||
|| !GooglePlayConstants.ACK_PENDING.equals(snapshot.getAcknowledgementState())
|
||||
|| !GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState())) {
|
||||
return false;
|
||||
}
|
||||
if (!GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType())) {
|
||||
return true;
|
||||
}
|
||||
return Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
|
||||
}
|
||||
|
||||
private boolean requiresConsume(GooglePlayPurchaseSnapshot snapshot,
|
||||
GooglePlayUserEntitlement entitlement,
|
||||
Long userId,
|
||||
GooglePlayOrder order) {
|
||||
if (userId == null || entitlement == null) {
|
||||
return false;
|
||||
}
|
||||
boolean oneTime = GooglePlayConstants.PRODUCT_TYPE_ONE_TIME.equals(snapshot.getProductType());
|
||||
boolean wallet = GooglePlayConstants.ENTITLEMENT_WALLET_TOP_UP.equals(entitlement.getBenefitType());
|
||||
boolean active = GooglePlayConstants.STATE_ACTIVE.equals(snapshot.getState());
|
||||
boolean pending = GooglePlayConstants.CONSUMPTION_PENDING.equals(snapshot.getConsumptionState());
|
||||
boolean owned = Boolean.TRUE.equals(order.getDeliveryOwnershipGranted());
|
||||
return oneTime && wallet && active && pending && owned;
|
||||
}
|
||||
|
||||
private String resolveLinkedToken(GooglePlayPurchaseSnapshot snapshot) {
|
||||
if (!GooglePlayConstants.STATE_PENDING_PURCHASE_CANCELED.equals(snapshot.getState())) {
|
||||
return null;
|
||||
}
|
||||
return snapshot.getLinkedPurchaseToken();
|
||||
}
|
||||
|
||||
private void saveOrder(GooglePlayOrder order) {
|
||||
if (order.getId() == null) {
|
||||
insertOrder(order);
|
||||
return;
|
||||
}
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
|
||||
/**
|
||||
* 仅在新订单场景预落库,避免并发请求用旧快照把发货状态回写成 PENDING。
|
||||
*/
|
||||
private void persistOrderIfNew(GooglePlayOrder order) {
|
||||
if (order.getId() == null) {
|
||||
saveOrder(order);
|
||||
}
|
||||
}
|
||||
|
||||
private void saveToken(GooglePlayPurchaseToken token) {
|
||||
if (token.getId() == null) {
|
||||
insertToken(token);
|
||||
return;
|
||||
}
|
||||
purchaseTokenMapper.updateById(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* 并发首写同一订单时,唯一键冲突后回读已存在记录继续流程。
|
||||
*/
|
||||
private void insertOrder(GooglePlayOrder order) {
|
||||
try {
|
||||
orderMapper.insert(order);
|
||||
} catch (DuplicateKeyException e) {
|
||||
GooglePlayOrder existingOrder = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||
.eq(GooglePlayOrder::getOrderKey, order.getOrderKey())
|
||||
.last("LIMIT 1"));
|
||||
if (existingOrder == null) {
|
||||
throw e;
|
||||
}
|
||||
order.setId(existingOrder.getId());
|
||||
order.setCreatedAt(existingOrder.getCreatedAt());
|
||||
order.setDeliveryStatus(existingOrder.getDeliveryStatus());
|
||||
order.setGrantedQuantity(existingOrder.getGrantedQuantity());
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* purchaseToken 首次并发写入时回读已有记录,避免重复插入直接失败。
|
||||
*/
|
||||
private void insertToken(GooglePlayPurchaseToken token) {
|
||||
try {
|
||||
purchaseTokenMapper.insert(token);
|
||||
} catch (DuplicateKeyException e) {
|
||||
GooglePlayPurchaseToken existingToken = findToken(token.getPurchaseToken());
|
||||
if (existingToken == null) {
|
||||
throw e;
|
||||
}
|
||||
token.setId(existingToken.getId());
|
||||
token.setCreatedAt(existingToken.getCreatedAt());
|
||||
purchaseTokenMapper.updateById(token);
|
||||
}
|
||||
}
|
||||
|
||||
private void updateAckState(String purchaseToken, String orderKey, String state) {
|
||||
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
||||
if (token != null) {
|
||||
token.setAcknowledgementState(state);
|
||||
token.setUpdatedAt(new Date());
|
||||
purchaseTokenMapper.updateById(token);
|
||||
}
|
||||
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||
.eq(GooglePlayOrder::getOrderKey, orderKey)
|
||||
.last("LIMIT 1"));
|
||||
if (order == null) {
|
||||
return;
|
||||
}
|
||||
order.setAcknowledgementState(state);
|
||||
order.setUpdatedAt(new Date());
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
|
||||
private void updateConsumptionState(String purchaseToken, String orderKey, String state) {
|
||||
GooglePlayPurchaseToken token = findToken(purchaseToken);
|
||||
if (token != null) {
|
||||
token.setConsumptionState(state);
|
||||
token.setUpdatedAt(new Date());
|
||||
purchaseTokenMapper.updateById(token);
|
||||
}
|
||||
GooglePlayOrder order = orderMapper.selectOne(Wrappers.<GooglePlayOrder>lambdaQuery()
|
||||
.eq(GooglePlayOrder::getOrderKey, orderKey)
|
||||
.last("LIMIT 1"));
|
||||
if (order == null) {
|
||||
return;
|
||||
}
|
||||
order.setConsumptionState(state);
|
||||
order.setUpdatedAt(new Date());
|
||||
orderMapper.updateById(order);
|
||||
}
|
||||
|
||||
private String defaultDeliveryStatus(GooglePlayPurchaseSnapshot snapshot) {
|
||||
if (GooglePlayConstants.PRODUCT_TYPE_SUBSCRIPTION.equals(snapshot.getProductType())) {
|
||||
return GooglePlayConstants.DELIVERY_NOT_REQUIRED;
|
||||
}
|
||||
return GooglePlayConstants.DELIVERY_PENDING;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.yolo.keyborad.common.ErrorCode;
|
||||
import com.yolo.keyborad.exception.BusinessException;
|
||||
import com.yolo.keyborad.model.entity.KeyboardProductItems;
|
||||
import com.yolo.keyborad.model.entity.KeyboardUser;
|
||||
import com.yolo.keyborad.service.UserService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayVipBenefitService {
|
||||
|
||||
private final UserService userService;
|
||||
|
||||
public void activate(Long userId, KeyboardProductItems product, Date expiry) {
|
||||
KeyboardUser user = requireUser(userId);
|
||||
user.setIsVip(true);
|
||||
user.setVipLevel(product.getLevel());
|
||||
user.setVipExpiry(expiry);
|
||||
if (!userService.updateById(user)) {
|
||||
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
public void deactivate(Long userId, Date expiry) {
|
||||
KeyboardUser user = requireUser(userId);
|
||||
user.setIsVip(false);
|
||||
user.setVipExpiry(expiry);
|
||||
if (!userService.updateById(user)) {
|
||||
throw new BusinessException(ErrorCode.UPDATE_USER_VIP_STATUS_ERROR);
|
||||
}
|
||||
}
|
||||
|
||||
private KeyboardUser requireUser(Long userId) {
|
||||
KeyboardUser user = userService.getById(userId);
|
||||
if (user == null) {
|
||||
throw new BusinessException(ErrorCode.USER_NOT_FOUND);
|
||||
}
|
||||
return user;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.yolo.keyborad.googleplay;
|
||||
|
||||
import com.yolo.keyborad.model.entity.KeyboardUserWallet;
|
||||
import com.yolo.keyborad.service.KeyboardUserWalletService;
|
||||
import com.yolo.keyborad.service.KeyboardWalletTransactionService;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class GooglePlayWalletBenefitService {
|
||||
|
||||
private static final short GOOGLE_PLAY_WALLET_TX_TYPE = 3;
|
||||
|
||||
private final KeyboardUserWalletService walletService;
|
||||
private final KeyboardWalletTransactionService walletTransactionService;
|
||||
|
||||
public void grant(Long userId, Long orderId, String productName, BigDecimal amount) {
|
||||
KeyboardUserWallet wallet = getOrCreateWallet(userId);
|
||||
BigDecimal before = defaultBalance(wallet.getBalance());
|
||||
BigDecimal after = before.add(amount);
|
||||
wallet.setBalance(after);
|
||||
wallet.setUpdatedAt(new Date());
|
||||
walletService.saveOrUpdate(wallet);
|
||||
walletTransactionService.createTransaction(userId, orderId, amount, GOOGLE_PLAY_WALLET_TX_TYPE,
|
||||
before, after, productName);
|
||||
}
|
||||
|
||||
public boolean revoke(Long userId, Long orderId, BigDecimal amount) {
|
||||
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||
.eq(KeyboardUserWallet::getUserId, userId)
|
||||
.one();
|
||||
BigDecimal before = wallet == null ? BigDecimal.ZERO : defaultBalance(wallet.getBalance());
|
||||
if (before.compareTo(amount) < 0) {
|
||||
log.error("Google Play 退款回滚失败,余额不足, userId={}, orderId={}", userId, orderId);
|
||||
return false;
|
||||
}
|
||||
BigDecimal after = before.subtract(amount);
|
||||
wallet.setBalance(after);
|
||||
wallet.setUpdatedAt(new Date());
|
||||
walletService.updateById(wallet);
|
||||
walletTransactionService.createTransaction(userId, orderId, amount.negate(), GOOGLE_PLAY_WALLET_TX_TYPE,
|
||||
before, after, "Google Play 退款回滚");
|
||||
return true;
|
||||
}
|
||||
|
||||
private KeyboardUserWallet getOrCreateWallet(Long userId) {
|
||||
KeyboardUserWallet wallet = walletService.lambdaQuery()
|
||||
.eq(KeyboardUserWallet::getUserId, userId)
|
||||
.one();
|
||||
if (wallet != null) {
|
||||
return wallet;
|
||||
}
|
||||
KeyboardUserWallet created = new KeyboardUserWallet();
|
||||
created.setUserId(userId);
|
||||
created.setBalance(BigDecimal.ZERO);
|
||||
created.setStatus((short) 1);
|
||||
created.setVersion(0);
|
||||
created.setCreatedAt(new Date());
|
||||
walletService.save(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
private BigDecimal defaultBalance(BigDecimal balance) {
|
||||
return balance == null ? BigDecimal.ZERO : balance;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
package com.yolo.keyborad.googleplay.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
public class GooglePlayPurchaseSnapshot {
|
||||
|
||||
private String packageName;
|
||||
|
||||
private String productId;
|
||||
|
||||
private String productType;
|
||||
|
||||
private String basePlanId;
|
||||
|
||||
private String purchaseToken;
|
||||
|
||||
private String orderKey;
|
||||
|
||||
private String googleOrderId;
|
||||
|
||||
private String linkedPurchaseToken;
|
||||
|
||||
private String state;
|
||||
|
||||
private String acknowledgementState;
|
||||
|
||||
private String consumptionState;
|
||||
|
||||
private Integer quantity;
|
||||
|
||||
private Integer refundableQuantity;
|
||||
|
||||
private Boolean autoRenewEnabled;
|
||||
|
||||
private Boolean accessGranted;
|
||||
|
||||
private String externalAccountId;
|
||||
|
||||
private String externalProfileId;
|
||||
|
||||
private String regionCode;
|
||||
|
||||
private String canceledStateReason;
|
||||
|
||||
private Date startTime;
|
||||
|
||||
private Date expiryTime;
|
||||
|
||||
private Date autoResumeTime;
|
||||
|
||||
private Date lastSyncedAt;
|
||||
|
||||
private String rawResponse;
|
||||
|
||||
private String purchaseOptionId;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yolo.keyborad.googleplay.model;
|
||||
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@Builder(toBuilder = true)
|
||||
public class GooglePlaySyncCommand {
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String packageName;
|
||||
|
||||
private String productId;
|
||||
|
||||
private String productType;
|
||||
|
||||
private String purchaseToken;
|
||||
|
||||
private String source;
|
||||
|
||||
private String eventType;
|
||||
|
||||
private Integer notificationType;
|
||||
|
||||
private String notificationName;
|
||||
|
||||
private String messageId;
|
||||
|
||||
private String subscriptionName;
|
||||
|
||||
private Date eventTime;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yolo.keyborad.googleplay.model;
|
||||
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public class GooglePlaySyncResult {
|
||||
|
||||
private GooglePlayOrder order;
|
||||
|
||||
private GooglePlayPurchaseToken token;
|
||||
|
||||
private GooglePlayUserEntitlement entitlement;
|
||||
|
||||
private boolean acknowledgeRequired;
|
||||
|
||||
private boolean consumeRequired;
|
||||
|
||||
private String linkedPurchaseTokenToSync;
|
||||
}
|
||||
@@ -20,7 +20,7 @@ import java.util.concurrent.TimeUnit;
|
||||
@Slf4j
|
||||
public class CharacterCacheInitializer implements ApplicationRunner {
|
||||
|
||||
private static final String CHARACTER_CACHE_KEY = "character:";
|
||||
private static final String CHARACTER_CACHE_KEY_PREFIX = "character:";
|
||||
|
||||
@Resource
|
||||
private KeyboardCharacterService characterService;
|
||||
@@ -34,7 +34,7 @@ public class CharacterCacheInitializer implements ApplicationRunner {
|
||||
log.info("开始缓存人设列表到Redis...");
|
||||
List<KeyboardCharacter> characters = characterService.list();
|
||||
for (KeyboardCharacter character : characters) {
|
||||
String key = CHARACTER_CACHE_KEY + character.getId();
|
||||
String key = CHARACTER_CACHE_KEY_PREFIX + character.getId();
|
||||
redisTemplate.opsForValue().set(key, character, 5, TimeUnit.MINUTES);
|
||||
}
|
||||
log.info("人设列表缓存完成,共缓存 {} 条记录", characters.size());
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayOrder;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
import org.apache.ibatis.annotations.Update;
|
||||
|
||||
public interface GooglePlayOrderMapper extends BaseMapper<GooglePlayOrder> {
|
||||
|
||||
/**
|
||||
* 原子抢占发货资格,只有当前状态匹配时才允许进入处理中。
|
||||
*/
|
||||
@Update("""
|
||||
UPDATE google_play_order
|
||||
SET delivery_status = #{targetStatus},
|
||||
updated_at = #{updatedAt}
|
||||
WHERE id = #{orderId}
|
||||
AND delivery_status = #{expectedStatus}
|
||||
""")
|
||||
int updateDeliveryStatusIfMatch(@Param("orderId") Long orderId,
|
||||
@Param("expectedStatus") String expectedStatus,
|
||||
@Param("targetStatus") String targetStatus,
|
||||
@Param("updatedAt") java.util.Date updatedAt);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayPurchaseToken;
|
||||
|
||||
public interface GooglePlayPurchaseTokenMapper extends BaseMapper<GooglePlayPurchaseToken> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayRtdnEvent;
|
||||
|
||||
public interface GooglePlayRtdnEventMapper extends BaseMapper<GooglePlayRtdnEvent> {
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.googleplay.GooglePlayUserEntitlement;
|
||||
|
||||
public interface GooglePlayUserEntitlementMapper extends BaseMapper<GooglePlayUserEntitlement> {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardAiCommentReport;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/20
|
||||
*/
|
||||
public interface KeyboardAiCommentReportMapper extends BaseMapper<KeyboardAiCommentReport> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardAiCompanionI18n;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/2 09:35
|
||||
*/
|
||||
|
||||
public interface KeyboardAiCompanionI18nMapper extends BaseMapper<KeyboardAiCompanionI18n> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardAppVersions;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/4 16:36
|
||||
*/
|
||||
|
||||
public interface KeyboardAppVersionsMapper extends BaseMapper<KeyboardAppVersions> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardCharacterI18n;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/1 14:13
|
||||
*/
|
||||
|
||||
public interface KeyboardCharacterI18nMapper extends BaseMapper<KeyboardCharacterI18n> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardCommentBlockRelation;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/23 10:30
|
||||
*/
|
||||
|
||||
public interface KeyboardCommentBlockRelationMapper extends BaseMapper<KeyboardCommentBlockRelation> {
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardTagI18n;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/2 13:54
|
||||
*/
|
||||
|
||||
public interface KeyboardTagI18nMapper extends BaseMapper<KeyboardTagI18n> {
|
||||
}
|
||||
@@ -14,11 +14,11 @@ import java.util.List;
|
||||
|
||||
public interface KeyboardUserCharacterMapper extends BaseMapper<KeyboardUserCharacter> {
|
||||
|
||||
List<KeyboardUserCharacterVO> selectByUserId(@Param("loginId") long loginId);
|
||||
List<KeyboardUserCharacterVO> selectByUserId(@Param("loginId") long loginId, @Param("locale") String locale);
|
||||
|
||||
void updateSortByIdAndUserId(@Param("sort") Integer[] sort,@Param("userId") long userId);
|
||||
|
||||
List<Long> selectSortByUserId(@Param("userId") Long userId);
|
||||
|
||||
void deleteByIdAndUserId(@Param("id") Long id, @Param("userId") long userId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.yolo.keyborad.mapper;
|
||||
|
||||
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||
import com.yolo.keyborad.model.entity.KeyboardWarningMessage;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/2/28 13:39
|
||||
*/
|
||||
|
||||
public interface KeyboardWarningMessageMapper extends BaseMapper<KeyboardWarningMessage> {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.yolo.keyborad.model.dto.appversion;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/4
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "App 更新检查请求")
|
||||
public class KeyboardAppUpdateCheckReq {
|
||||
|
||||
@Schema(description = "应用标识;单 App 可固定为 main", example = "main")
|
||||
private String appId;
|
||||
|
||||
@Schema(description = "平台:android/ios", example = "ios")
|
||||
private String platform;
|
||||
|
||||
@Schema(description = "渠道:official/testflight/...", example = "official")
|
||||
private String channel;
|
||||
|
||||
@Schema(description = "客户端版本号(整数递增):Android=versionCode,iOS 建议维护同样的递增值", example = "100")
|
||||
private Long clientVersionCode;
|
||||
|
||||
@Schema(description = "客户端展示版本号(可选):如 1.2.3", example = "1.2.3")
|
||||
private String clientVersionName;
|
||||
|
||||
@Schema(description = "客户端构建号(可选):iOS CFBundleVersion 等", example = "2026030401")
|
||||
private String buildNumber;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yolo.keyborad.model.dto.comment;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/23
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "评论区拉黑请求")
|
||||
public class CommentBlockReq {
|
||||
|
||||
@Schema(description = "被拉黑用户ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long blockedUserId;
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.yolo.keyborad.model.dto.comment;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/20
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "删除评论请求")
|
||||
public class CommentDeleteReq {
|
||||
|
||||
@Schema(description = "评论ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long commentId;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package com.yolo.keyborad.model.dto.comment;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/20
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "评论举报请求")
|
||||
public class CommentReportReq {
|
||||
|
||||
@Schema(description = "评论ID", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private Long commentId;
|
||||
|
||||
@Schema(description = "举报类型列表:1=色情低俗, 2=政治敏感, 3=暴力恐怖, 4=侵权/冒充, 5=价值观问题, 99=其他,支持多选", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<Short> reportTypes;
|
||||
|
||||
@Schema(description = "详细描述")
|
||||
private String reportDesc;
|
||||
|
||||
@Schema(description = "评论内容")
|
||||
private String commentContext;
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.yolo.keyborad.model.dto.googleplay;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
public class GooglePlayPubSubPushRequest {
|
||||
|
||||
private Message message;
|
||||
|
||||
private String subscription;
|
||||
|
||||
@Data
|
||||
public static class Message {
|
||||
private Map<String, String> attributes;
|
||||
private String data;
|
||||
private String messageId;
|
||||
private String publishTime;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class DeveloperNotification {
|
||||
private String version;
|
||||
private String packageName;
|
||||
private String eventTimeMillis;
|
||||
private SubscriptionNotification subscriptionNotification;
|
||||
private OneTimeProductNotification oneTimeProductNotification;
|
||||
private VoidedPurchaseNotification voidedPurchaseNotification;
|
||||
private TestNotification testNotification;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class SubscriptionNotification {
|
||||
private String version;
|
||||
private Integer notificationType;
|
||||
private String purchaseToken;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class OneTimeProductNotification {
|
||||
private String version;
|
||||
private Integer notificationType;
|
||||
private String purchaseToken;
|
||||
private String sku;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class VoidedPurchaseNotification {
|
||||
private String purchaseToken;
|
||||
private String orderId;
|
||||
private Integer productType;
|
||||
private Integer refundType;
|
||||
}
|
||||
|
||||
@Data
|
||||
public static class TestNotification {
|
||||
private String version;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.yolo.keyborad.model.dto.googleplay;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class GooglePlayPurchaseVerifyReq {
|
||||
|
||||
private String packageName;
|
||||
|
||||
private String productId;
|
||||
|
||||
private String productType;
|
||||
|
||||
private String purchaseToken;
|
||||
}
|
||||
@@ -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 lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/20
|
||||
*/
|
||||
|
||||
/**
|
||||
* AI评论举报记录表
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "AI评论举报记录表")
|
||||
@TableName(value = "keyboard_ai_comment_report")
|
||||
public class KeyboardAiCommentReport {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description = "举报记录唯一ID")
|
||||
private Long id;
|
||||
|
||||
@TableField(value = "comment_id")
|
||||
@Schema(description = "被举报的评论ID")
|
||||
private Long commentId;
|
||||
|
||||
@TableField(value = "user_id")
|
||||
@Schema(description = "发起举报的用户ID")
|
||||
private Long userId;
|
||||
|
||||
@TableField(value = "report_type")
|
||||
@Schema(description = "举报类型,多选时逗号分隔")
|
||||
private String reportType;
|
||||
|
||||
@TableField(value = "report_desc")
|
||||
@Schema(description = "用户填写的详细举报描述")
|
||||
private String reportDesc;
|
||||
|
||||
@TableField(value = "comment_context")
|
||||
@Schema(description = "评论上下文快照JSON")
|
||||
private String commentContext;
|
||||
|
||||
@TableField(value = "\"status\"")
|
||||
@Schema(description = "处理状态:0=待处理, 1=违规确立, 2=无效举报, 3=已忽略")
|
||||
private Short status;
|
||||
|
||||
@TableField(value = "admin_remark")
|
||||
@Schema(description = "管理员处理备注")
|
||||
private String adminRemark;
|
||||
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description = "举报提交时间")
|
||||
private Date createdAt;
|
||||
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description = "最后更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -27,12 +27,6 @@ public class KeyboardAiCompanion {
|
||||
@Schema(description="陪聊角色唯一ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 角色名称(展示用,如:Katie Leona)
|
||||
*/
|
||||
@TableField(value = "\"name\"")
|
||||
@Schema(description="角色名称(展示用,如:Katie Leona)")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 角色头像URL,用于列表页和聊天页
|
||||
@@ -62,20 +56,6 @@ public class KeyboardAiCompanion {
|
||||
@Schema(description="角色年龄段描述(如:20s、25-30)")
|
||||
private String ageRange;
|
||||
|
||||
/**
|
||||
* 一句话人设描述,用于卡片或列表展示
|
||||
*/
|
||||
@TableField(value = "short_desc")
|
||||
@Schema(description="一句话人设描述,用于卡片或列表展示")
|
||||
private String shortDesc;
|
||||
|
||||
/**
|
||||
* 角色详细介绍文案,用于角色详情页
|
||||
*/
|
||||
@TableField(value = "intro_text")
|
||||
@Schema(description="角色详细介绍文案,用于角色详情页")
|
||||
private String introText;
|
||||
|
||||
/**
|
||||
* 角色性格标签数组(如:温柔、黏人、治愈)
|
||||
*/
|
||||
@@ -139,15 +119,11 @@ public class KeyboardAiCompanion {
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
|
||||
@TableField(value = "prologue")
|
||||
@Schema(description="开场白")
|
||||
private String prologue;
|
||||
|
||||
@TableField(value = "prologue_audio")
|
||||
@Schema(description="开场白音频")
|
||||
private String prologueAudio;
|
||||
|
||||
@TableField(value = "voice_id")
|
||||
@Schema(description="角色音频Id")
|
||||
private String voiceId;
|
||||
|
||||
@TableField(value = "deleted")
|
||||
@Schema(description = "是否删除")
|
||||
private Byte deleted;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
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: 2026/4/2 09:35
|
||||
*/
|
||||
|
||||
/**
|
||||
* AI陪聊角色国际化表,用于存储不同语言下的角色名称、一句话描述和详细介绍
|
||||
*/
|
||||
@Schema(description="AI陪聊角色国际化表,用于存储不同语言下的角色名称、一句话描述和详细介绍")
|
||||
@Data
|
||||
@TableName(value = "keyboard_ai_companion_i18n")
|
||||
public class KeyboardAiCompanionI18n {
|
||||
/**
|
||||
* 主键ID
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键ID")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 陪聊角色主表ID,对应 keyboard_ai_companion.id
|
||||
*/
|
||||
@TableField(value = "companion_id")
|
||||
@Schema(description="陪聊角色主表ID,对应 keyboard_ai_companion.id")
|
||||
private Long companionId;
|
||||
|
||||
/**
|
||||
* 语言标识,如 zh-CN、en-US、ja-JP
|
||||
*/
|
||||
@TableField(value = "\"locale\"")
|
||||
@Schema(description="语言标识,如 zh-CN、en-US、ja-JP")
|
||||
private String locale;
|
||||
|
||||
/**
|
||||
* 角色名称(多语言)
|
||||
*/
|
||||
@TableField(value = "\"name\"")
|
||||
@Schema(description="角色名称(多语言)")
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 一句话人设描述(多语言)
|
||||
*/
|
||||
@TableField(value = "short_desc")
|
||||
@Schema(description="一句话人设描述(多语言)")
|
||||
private String shortDesc;
|
||||
|
||||
/**
|
||||
* 角色详细介绍文案(多语言)
|
||||
*/
|
||||
@TableField(value = "intro_text")
|
||||
@Schema(description="角色详细介绍文案(多语言)")
|
||||
private String introText;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
|
||||
@TableField(value = "prologue")
|
||||
@Schema(description = "开场白")
|
||||
private String prologue;
|
||||
|
||||
@TableField(value = "prologue_audio")
|
||||
@Schema(description = "开场白音频")
|
||||
private String prologueAudio;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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: 2026/3/4 16:36
|
||||
*/
|
||||
|
||||
/**
|
||||
* App 版本发布与更新检查表:区分 Android/iOS、渠道,支持最低支持版本与强更策略。
|
||||
*/
|
||||
@Schema(description="App 版本发布与更新检查表:区分 Android/iOS、渠道,支持最低支持版本与强更策略。")
|
||||
@Data
|
||||
@TableName(value = "keyboard_app_versions")
|
||||
public class KeyboardAppVersions {
|
||||
/**
|
||||
* 主键,自增版本记录ID。
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键,自增版本记录ID。")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 应用标识(支持多App/多包名场景);单App可固定为 main。
|
||||
*/
|
||||
@TableField(value = "app_id")
|
||||
@Schema(description="应用标识(支持多App/多包名场景);单App可固定为 main。")
|
||||
private String appId;
|
||||
|
||||
/**
|
||||
* 平台:android 或 ios(用 CHECK 约束限制取值)。
|
||||
*/
|
||||
@TableField(value = "platform")
|
||||
@Schema(description="平台:android 或 ios(用 CHECK 约束限制取值)。")
|
||||
private String platform;
|
||||
|
||||
/**
|
||||
* 渠道标识:如 official / huawei / xiaomi / testflight 等,用于区分不同分发包。
|
||||
*/
|
||||
@TableField(value = "channel")
|
||||
@Schema(description="渠道标识:如 official / huawei / xiaomi / testflight 等,用于区分不同分发包。")
|
||||
private String channel;
|
||||
|
||||
/**
|
||||
* 展示用版本号(语义版本字符串),如 1.2.3。
|
||||
*/
|
||||
@TableField(value = "version_name")
|
||||
@Schema(description="展示用版本号(语义版本字符串),如 1.2.3。")
|
||||
private String versionName;
|
||||
|
||||
/**
|
||||
* 比较用版本号(整数递增):Android 对应 versionCode;iOS 建议维护同样的递增值以便比较。
|
||||
*/
|
||||
@TableField(value = "version_code")
|
||||
@Schema(description="比较用版本号(整数递增):Android 对应 versionCode;iOS 建议维护同样的递增值以便比较。")
|
||||
private Long versionCode;
|
||||
|
||||
/**
|
||||
* iOS 可选构建号(例如 CFBundleVersion),通常为字符串;用于追溯构建或与CI编号对齐。
|
||||
*/
|
||||
@TableField(value = "build_number")
|
||||
@Schema(description="iOS 可选构建号(例如 CFBundleVersion),通常为字符串;用于追溯构建或与CI编号对齐。")
|
||||
private String buildNumber;
|
||||
|
||||
/**
|
||||
* 最低支持版本号(整数):客户端 version_code 低于该值必须更新/可拒绝继续使用。
|
||||
*/
|
||||
@TableField(value = "min_supported_code")
|
||||
@Schema(description="最低支持版本号(整数):客户端 version_code 低于该值必须更新/可拒绝继续使用。")
|
||||
private Long minSupportedCode;
|
||||
|
||||
/**
|
||||
* 是否强制更新:当客户端未达到最新版本且此字段为 true,可要求强更(即使 >= min_supported_code)。
|
||||
*/
|
||||
@TableField(value = "is_force_update")
|
||||
@Schema(description="是否强制更新:当客户端未达到最新版本且此字段为 true,可要求强更(即使 >= min_supported_code)。")
|
||||
private Boolean isForceUpdate;
|
||||
|
||||
/**
|
||||
* 是否生效:true 表示该版本记录可用于对外更新检查;false 用于下架/撤回。
|
||||
*/
|
||||
@TableField(value = "is_active")
|
||||
@Schema(description="是否生效:true 表示该版本记录可用于对外更新检查;false 用于下架/撤回。")
|
||||
private Boolean isActive;
|
||||
|
||||
/**
|
||||
* 更新说明(展示给用户的版本更新内容)。
|
||||
*/
|
||||
@TableField(value = "release_notes")
|
||||
@Schema(description="更新说明(展示给用户的版本更新内容)。")
|
||||
private String releaseNotes;
|
||||
|
||||
/**
|
||||
* 下载链接:Android 可为 apk 直链/市场 scheme;iOS 通常为 App Store 链接或统一跳转页。
|
||||
*/
|
||||
@TableField(value = "download_url")
|
||||
@Schema(description="下载链接:Android 可为 apk 直链/市场 scheme;iOS 通常为 App Store 链接或统一跳转页。")
|
||||
private String downloadUrl;
|
||||
|
||||
/**
|
||||
* 应用市场/商店页面链接(可选,若 download_url 已覆盖可不填)。
|
||||
*/
|
||||
@TableField(value = "store_url")
|
||||
@Schema(description="应用市场/商店页面链接(可选,若 download_url 已覆盖可不填)。")
|
||||
private String storeUrl;
|
||||
|
||||
/**
|
||||
* 扩展元数据(JSON):如包大小、md5、签名信息、最低系统版本等。
|
||||
*/
|
||||
@TableField(value = "metadata")
|
||||
@Schema(description="扩展元数据(JSON):如包大小、md5、签名信息、最低系统版本等。")
|
||||
private Object metadata;
|
||||
|
||||
/**
|
||||
* 发布时间(对外宣布/上线时间),用于展示与排序。
|
||||
*/
|
||||
@TableField(value = "released_at")
|
||||
@Schema(description="发布时间(对外宣布/上线时间),用于展示与排序。")
|
||||
private Date releasedAt;
|
||||
|
||||
/**
|
||||
* 记录创建时间。
|
||||
*/
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="记录创建时间。")
|
||||
private Date createdAt;
|
||||
|
||||
/**
|
||||
* 记录更新时间(建议配合触发器自动维护)。
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="记录更新时间(建议配合触发器自动维护)。")
|
||||
private Date updatedAt;
|
||||
|
||||
/**
|
||||
* 是否删除
|
||||
*/
|
||||
@TableField(value = "deleted")
|
||||
@Schema(description="是否删除")
|
||||
private Boolean deleted;
|
||||
}
|
||||
@@ -21,14 +21,6 @@ public class KeyboardCharacter {
|
||||
@Schema(description="主键 Id")
|
||||
private Long id;
|
||||
|
||||
@TableField(value = "character_name")
|
||||
@Schema(description="标题")
|
||||
private String characterName;
|
||||
|
||||
@TableField(value = "\"character_background\"")
|
||||
@Schema(description="背景描述")
|
||||
private String characterBackground;
|
||||
|
||||
@TableField(value = "avatar_url")
|
||||
@Schema(description="角色头像")
|
||||
private String avatarUrl;
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
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: 2026/4/1 14:13
|
||||
*/
|
||||
|
||||
/**
|
||||
* 键盘人设国际化内容表
|
||||
*/
|
||||
@Schema(description="键盘人设国际化内容表")
|
||||
@Data
|
||||
@TableName(value = "keyboard_character_i18n")
|
||||
public class KeyboardCharacterI18n {
|
||||
/**
|
||||
* 主键 Id
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键 Id")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 角色主表id
|
||||
*/
|
||||
@TableField(value = "character_id")
|
||||
@Schema(description="角色主表id")
|
||||
private Long characterId;
|
||||
|
||||
/**
|
||||
* 语言标识,如 zh-CN/en-US/ja-JP
|
||||
*/
|
||||
@TableField(value = "\"locale\"")
|
||||
@Schema(description="语言标识,如 zh-CN/en-US/ja-JP")
|
||||
private String locale;
|
||||
|
||||
/**
|
||||
* 标题
|
||||
*/
|
||||
@TableField(value = "character_name")
|
||||
@Schema(description="标题")
|
||||
private String characterName;
|
||||
|
||||
/**
|
||||
* 背景描述
|
||||
*/
|
||||
@TableField(value = "character_background")
|
||||
@Schema(description="背景描述")
|
||||
private String characterBackground;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
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: 2026/3/23 10:30
|
||||
*/
|
||||
|
||||
/**
|
||||
* 评论区拉黑关系表
|
||||
*/
|
||||
@Schema(description="评论区拉黑关系表")
|
||||
@Data
|
||||
@TableName(value = "keyboard_comment_block_relation")
|
||||
public class KeyboardCommentBlockRelation {
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 拉黑发起人
|
||||
*/
|
||||
@TableField(value = "blocker_user_id")
|
||||
@Schema(description="拉黑发起人")
|
||||
private Long blockerUserId;
|
||||
|
||||
/**
|
||||
* 被拉黑用户
|
||||
*/
|
||||
@TableField(value = "blocked_user_id")
|
||||
@Schema(description="被拉黑用户")
|
||||
private Long blockedUserId;
|
||||
|
||||
/**
|
||||
* 作用域类型: 1评论区全局 2帖子 3圈子 4直播间
|
||||
*/
|
||||
@TableField(value = "scope_type")
|
||||
@Schema(description="作用域类型: 1评论区全局 2帖子 3圈子 4直播间")
|
||||
private Short scopeType;
|
||||
|
||||
/**
|
||||
* 作用域ID, 全局为0
|
||||
*/
|
||||
@TableField(value = "scope_id")
|
||||
@Schema(description="作用域ID, 全局为0")
|
||||
private Long scopeId;
|
||||
|
||||
/**
|
||||
* 状态: 1有效 0取消
|
||||
*/
|
||||
@TableField(value = "\"status\"")
|
||||
@Schema(description="状态: 1有效 0取消")
|
||||
private Short status;
|
||||
|
||||
/**
|
||||
* 来源: 1用户操作 2风控 3管理后台
|
||||
*/
|
||||
@TableField(value = "\"source\"")
|
||||
@Schema(description="来源: 1用户操作 2风控 3管理后台")
|
||||
private Short source;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
|
||||
/**
|
||||
* 删除时间
|
||||
*/
|
||||
@TableField(value = "deleted_at")
|
||||
@Schema(description="删除时间")
|
||||
private Date deletedAt;
|
||||
}
|
||||
@@ -112,4 +112,8 @@ public class KeyboardProductItems {
|
||||
@TableField(value = "level")
|
||||
@Schema(description = "级别")
|
||||
private Integer level;
|
||||
|
||||
@TableField(value = "platform")
|
||||
@Schema(description = "所属平台")
|
||||
private String platform;
|
||||
}
|
||||
@@ -22,10 +22,6 @@ public class KeyboardTag {
|
||||
@Schema(description="主键 id")
|
||||
private Integer id;
|
||||
|
||||
@TableField(value = "tag_name")
|
||||
@Schema(description="标签名")
|
||||
private String tagName;
|
||||
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
@@ -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: 2026/4/2 13:54
|
||||
*/
|
||||
|
||||
/**
|
||||
* 人设标签国际化表
|
||||
*/
|
||||
@Schema(description="人设标签国际化表")
|
||||
@Data
|
||||
@TableName(value = "keyboard_tag_i18n")
|
||||
public class KeyboardTagI18n {
|
||||
/**
|
||||
* 主键 Id
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键 Id")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 标签主表ID,对应 keyboard_tag.id
|
||||
*/
|
||||
@TableField(value = "tag_id")
|
||||
@Schema(description="标签主表ID,对应 keyboard_tag.id")
|
||||
private Integer tagId;
|
||||
|
||||
/**
|
||||
* 语言标识,如 zh-CN、en-US、ja-JP
|
||||
*/
|
||||
@TableField(value = "\"locale\"")
|
||||
@Schema(description="语言标识,如 zh-CN、en-US、ja-JP")
|
||||
private String locale;
|
||||
|
||||
/**
|
||||
* 标签名称(多语言)
|
||||
*/
|
||||
@TableField(value = "tag_name")
|
||||
@Schema(description="标签名称(多语言)")
|
||||
private String tagName;
|
||||
|
||||
/**
|
||||
* 创建时间
|
||||
*/
|
||||
@TableField(value = "created_at")
|
||||
@Schema(description="创建时间")
|
||||
private Date createdAt;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -45,4 +45,8 @@ public class KeyboardThemeStyles {
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="")
|
||||
private Date updatedAt;
|
||||
}
|
||||
|
||||
@TableField(value = "local")
|
||||
@Schema(description = "所属国家")
|
||||
private String local;
|
||||
}
|
||||
|
||||
@@ -102,4 +102,8 @@ public class KeyboardThemes {
|
||||
@TableField(value = "real_download_count")
|
||||
@Schema(description = "真实下载数量")
|
||||
private Long realDownloadCount;
|
||||
|
||||
@TableField(value = "local")
|
||||
@Schema(description = "所属国家")
|
||||
private String local;
|
||||
}
|
||||
@@ -132,4 +132,8 @@ public class KeyboardUser {
|
||||
@TableField(value = "vip_level")
|
||||
@Schema(description = "vip等级")
|
||||
private Integer vipLevel;
|
||||
|
||||
@TableField(value = "uuid")
|
||||
@Schema(description = "uuid")
|
||||
private String uuid;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
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: 2026/2/28 13:39
|
||||
*/
|
||||
|
||||
/**
|
||||
* 用户注销提示信息表
|
||||
*/
|
||||
@Schema(description="用户注销提示信息表")
|
||||
@Data
|
||||
@TableName(value = "keyboard_warning_message")
|
||||
public class KeyboardWarningMessage {
|
||||
/**
|
||||
* 主键
|
||||
*/
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
@Schema(description="主键")
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 地区
|
||||
*/
|
||||
@TableField(value = "\"locale\"")
|
||||
@Schema(description="地区")
|
||||
private String locale;
|
||||
|
||||
/**
|
||||
* 正文
|
||||
*/
|
||||
@TableField(value = "content")
|
||||
@Schema(description="正文")
|
||||
private String content;
|
||||
|
||||
/**
|
||||
* 更新时间
|
||||
*/
|
||||
@TableField(value = "updated_at")
|
||||
@Schema(description="更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.yolo.keyborad.model.entity.googleplay;
|
||||
|
||||
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 lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("google_play_order")
|
||||
public class GooglePlayOrder {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
@TableField("package_name")
|
||||
private String packageName;
|
||||
|
||||
@TableField("product_id")
|
||||
private String productId;
|
||||
|
||||
@TableField("product_type")
|
||||
private String productType;
|
||||
|
||||
@TableField("purchase_token")
|
||||
private String purchaseToken;
|
||||
|
||||
@TableField("order_key")
|
||||
private String orderKey;
|
||||
|
||||
@TableField("google_order_id")
|
||||
private String googleOrderId;
|
||||
|
||||
@TableField("linked_purchase_token")
|
||||
private String linkedPurchaseToken;
|
||||
|
||||
@TableField("order_state")
|
||||
private String orderState;
|
||||
|
||||
@TableField("acknowledgement_state")
|
||||
private String acknowledgementState;
|
||||
|
||||
@TableField("consumption_state")
|
||||
private String consumptionState;
|
||||
|
||||
@TableField("quantity")
|
||||
private Integer quantity;
|
||||
|
||||
@TableField("refundable_quantity")
|
||||
private Integer refundableQuantity;
|
||||
|
||||
@TableField("delivery_status")
|
||||
private String deliveryStatus;
|
||||
|
||||
@TableField("granted_quantity")
|
||||
private BigDecimal grantedQuantity;
|
||||
|
||||
@TableField("entitlement_start_time")
|
||||
private Date entitlementStartTime;
|
||||
|
||||
@TableField("entitlement_end_time")
|
||||
private Date entitlementEndTime;
|
||||
|
||||
@TableField("last_event_time")
|
||||
private Date lastEventTime;
|
||||
|
||||
@TableField("last_synced_at")
|
||||
private Date lastSyncedAt;
|
||||
|
||||
@TableField("raw_response")
|
||||
private String rawResponse;
|
||||
|
||||
@TableField("created_at")
|
||||
private Date createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private Date updatedAt;
|
||||
|
||||
/**
|
||||
* 当前线程是否拿到了本次发货资格,仅用于本次请求内控制幂等,不落库。
|
||||
*/
|
||||
@TableField(exist = false)
|
||||
private Boolean deliveryOwnershipGranted;
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.yolo.keyborad.model.entity.googleplay;
|
||||
|
||||
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 lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("google_play_purchase_token")
|
||||
public class GooglePlayPurchaseToken {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("purchase_token")
|
||||
private String purchaseToken;
|
||||
|
||||
@TableField("linked_purchase_token")
|
||||
private String linkedPurchaseToken;
|
||||
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
@TableField("package_name")
|
||||
private String packageName;
|
||||
|
||||
@TableField("product_id")
|
||||
private String productId;
|
||||
|
||||
@TableField("product_type")
|
||||
private String productType;
|
||||
|
||||
@TableField("latest_order_key")
|
||||
private String latestOrderKey;
|
||||
|
||||
@TableField("latest_order_id")
|
||||
private String latestOrderId;
|
||||
|
||||
@TableField("token_state")
|
||||
private String tokenState;
|
||||
|
||||
@TableField("acknowledgement_state")
|
||||
private String acknowledgementState;
|
||||
|
||||
@TableField("consumption_state")
|
||||
private String consumptionState;
|
||||
|
||||
@TableField("auto_renew_enabled")
|
||||
private Boolean autoRenewEnabled;
|
||||
|
||||
@TableField("external_account_id")
|
||||
private String externalAccountId;
|
||||
|
||||
@TableField("external_profile_id")
|
||||
private String externalProfileId;
|
||||
|
||||
@TableField("region_code")
|
||||
private String regionCode;
|
||||
|
||||
@TableField("start_time")
|
||||
private Date startTime;
|
||||
|
||||
@TableField("expiry_time")
|
||||
private Date expiryTime;
|
||||
|
||||
@TableField("auto_resume_time")
|
||||
private Date autoResumeTime;
|
||||
|
||||
@TableField("canceled_state_reason")
|
||||
private String canceledStateReason;
|
||||
|
||||
@TableField("last_event_type")
|
||||
private String lastEventType;
|
||||
|
||||
@TableField("last_event_time")
|
||||
private Date lastEventTime;
|
||||
|
||||
@TableField("last_synced_at")
|
||||
private Date lastSyncedAt;
|
||||
|
||||
@TableField("raw_response")
|
||||
private String rawResponse;
|
||||
|
||||
@TableField("created_at")
|
||||
private Date createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.yolo.keyborad.model.entity.googleplay;
|
||||
|
||||
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 lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("google_play_rtdn_event")
|
||||
public class GooglePlayRtdnEvent {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("message_id")
|
||||
private String messageId;
|
||||
|
||||
@TableField("subscription_name")
|
||||
private String subscriptionName;
|
||||
|
||||
@TableField("package_name")
|
||||
private String packageName;
|
||||
|
||||
@TableField("event_type")
|
||||
private String eventType;
|
||||
|
||||
@TableField("notification_type")
|
||||
private Integer notificationType;
|
||||
|
||||
@TableField("notification_name")
|
||||
private String notificationName;
|
||||
|
||||
@TableField("purchase_token")
|
||||
private String purchaseToken;
|
||||
|
||||
@TableField("product_id")
|
||||
private String productId;
|
||||
|
||||
@TableField("order_id")
|
||||
private String orderId;
|
||||
|
||||
@TableField("event_time")
|
||||
private Date eventTime;
|
||||
|
||||
@TableField("status")
|
||||
private String status;
|
||||
|
||||
@TableField("retry_count")
|
||||
private Integer retryCount;
|
||||
|
||||
@TableField("raw_envelope")
|
||||
private String rawEnvelope;
|
||||
|
||||
@TableField("raw_payload")
|
||||
private String rawPayload;
|
||||
|
||||
@TableField("error_message")
|
||||
private String errorMessage;
|
||||
|
||||
@TableField("processed_at")
|
||||
private Date processedAt;
|
||||
|
||||
@TableField("created_at")
|
||||
private Date createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.yolo.keyborad.model.entity.googleplay;
|
||||
|
||||
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 lombok.Data;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
@TableName("google_play_user_entitlement")
|
||||
public class GooglePlayUserEntitlement {
|
||||
|
||||
@TableId(value = "id", type = IdType.AUTO)
|
||||
private Long id;
|
||||
|
||||
@TableField("user_id")
|
||||
private Long userId;
|
||||
|
||||
@TableField("entitlement_key")
|
||||
private String entitlementKey;
|
||||
|
||||
@TableField("product_id")
|
||||
private String productId;
|
||||
|
||||
@TableField("product_type")
|
||||
private String productType;
|
||||
|
||||
@TableField("source_purchase_token")
|
||||
private String sourcePurchaseToken;
|
||||
|
||||
@TableField("current_order_key")
|
||||
private String currentOrderKey;
|
||||
|
||||
@TableField("benefit_type")
|
||||
private String benefitType;
|
||||
|
||||
@TableField("state")
|
||||
private String state;
|
||||
|
||||
@TableField("active")
|
||||
private Boolean active;
|
||||
|
||||
@TableField("quantity")
|
||||
private BigDecimal quantity;
|
||||
|
||||
@TableField("start_time")
|
||||
private Date startTime;
|
||||
|
||||
@TableField("end_time")
|
||||
private Date endTime;
|
||||
|
||||
@TableField("last_granted_at")
|
||||
private Date lastGrantedAt;
|
||||
|
||||
@TableField("last_revoked_at")
|
||||
private Date lastRevokedAt;
|
||||
|
||||
@TableField("metadata")
|
||||
private String metadata;
|
||||
|
||||
@TableField("created_at")
|
||||
private Date createdAt;
|
||||
|
||||
@TableField("updated_at")
|
||||
private Date updatedAt;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.yolo.keyborad.model.vo;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/23
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "评论区已拉黑用户")
|
||||
public class CommentBlockedUserVO {
|
||||
|
||||
@Schema(description = "用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "用户UID")
|
||||
private Long userUid;
|
||||
|
||||
@Schema(description = "用户昵称")
|
||||
private String userName;
|
||||
|
||||
@Schema(description = "用户头像")
|
||||
private String userAvatar;
|
||||
|
||||
@Schema(description = "拉黑时间")
|
||||
private Date blockedAt;
|
||||
}
|
||||
@@ -23,6 +23,9 @@ public class CommentVO {
|
||||
@Schema(description = "发表评论的用户ID")
|
||||
private Long userId;
|
||||
|
||||
@Schema(description = "发表评论的用户UID")
|
||||
private Long userUid;
|
||||
|
||||
@Schema(description = "用户昵称")
|
||||
private String userName;
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.yolo.keyborad.model.vo.appversion;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/4
|
||||
*/
|
||||
@Data
|
||||
@Schema(description = "App 更新检查响应")
|
||||
public class KeyboardAppUpdateCheckRespVO {
|
||||
|
||||
@Schema(description = "是否需要更新(客户端版本 < 最新版本)")
|
||||
private Boolean needUpdate;
|
||||
|
||||
@Schema(description = "是否强制更新(客户端版本 < 最低支持版本 或 最新版本标记为强更)")
|
||||
private Boolean forceUpdate;
|
||||
|
||||
@Schema(description = "最新版本展示号,如 1.2.3")
|
||||
private String latestVersionName;
|
||||
|
||||
@Schema(description = "最新版本比较号(整数递增)")
|
||||
private Long latestVersionCode;
|
||||
|
||||
@Schema(description = "最低支持版本比较号(整数);客户端低于该值必须更新")
|
||||
private Long minSupportedCode;
|
||||
|
||||
@Schema(description = "更新说明")
|
||||
private String releaseNotes;
|
||||
|
||||
@Schema(description = "下载链接/市场 scheme")
|
||||
private String downloadUrl;
|
||||
|
||||
@Schema(description = "商店页面链接")
|
||||
private String storeUrl;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.yolo.keyborad.model.vo.googleplay;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.util.Date;
|
||||
|
||||
@Data
|
||||
public class GooglePlayPurchaseVerifyResp {
|
||||
|
||||
private Long userId;
|
||||
|
||||
private String productId;
|
||||
|
||||
private String productType;
|
||||
|
||||
private String purchaseToken;
|
||||
|
||||
private String orderId;
|
||||
|
||||
private String orderState;
|
||||
|
||||
private String entitlementState;
|
||||
|
||||
private String deliveryStatus;
|
||||
|
||||
private Boolean accessGranted;
|
||||
|
||||
private Boolean acknowledged;
|
||||
|
||||
private Boolean consumed;
|
||||
|
||||
private Date expiryTime;
|
||||
|
||||
private Date lastSyncedAt;
|
||||
}
|
||||
@@ -45,6 +45,9 @@ public class KeyboardProductItemRespVO {
|
||||
private String description;
|
||||
|
||||
@Schema(description = "级别")
|
||||
private Integer level;
|
||||
private Integer level;
|
||||
|
||||
@Schema(description = "所属平台")
|
||||
private String platform;
|
||||
}
|
||||
|
||||
|
||||
@@ -70,4 +70,7 @@ public class KeyboardThemesRespVO {
|
||||
|
||||
@Schema(description = "当前用户是否已购买")
|
||||
private Boolean isPurchased;
|
||||
|
||||
@Schema(description = "所属国家")
|
||||
private String local;
|
||||
}
|
||||
|
||||
@@ -57,4 +57,6 @@ public class KeyboardUserInfoRespVO {
|
||||
@Schema(description = "vip等级")
|
||||
private Integer vipLevel;
|
||||
|
||||
@Schema(description = "uuid")
|
||||
private String uuid;
|
||||
}
|
||||
@@ -59,4 +59,8 @@ public class KeyboardUserRespVO {
|
||||
@TableField(value = "vip_level")
|
||||
@Schema(description = "vip等级")
|
||||
private Integer vipLevel;
|
||||
|
||||
@TableField(value = "uuid")
|
||||
@Schema(description = "uuid")
|
||||
private String uuid;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package com.yolo.keyborad.model.vo.warning;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.Date;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "用户注销提示信息返回")
|
||||
@Data
|
||||
public class KeyboardWarningMessageRespVO {
|
||||
|
||||
@Schema(description = "地区")
|
||||
private String locale;
|
||||
|
||||
@Schema(description = "正文")
|
||||
private String content;
|
||||
|
||||
@Schema(description = "更新时间")
|
||||
private Date updatedAt;
|
||||
}
|
||||
|
||||
@@ -43,4 +43,11 @@ public interface ApplePurchaseService {
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void handleConsumptionRequest(ResponseBodyV2DecodedPayload notification);
|
||||
|
||||
/**
|
||||
* 处理一次性购买通知(ONE_TIME_CHARGE)
|
||||
*
|
||||
* @param notification 解码后的通知载荷
|
||||
*/
|
||||
void handleOneTimeChargeNotification(ResponseBodyV2DecodedPayload notification);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPubSubPushRequest;
|
||||
import com.yolo.keyborad.model.dto.googleplay.GooglePlayPurchaseVerifyReq;
|
||||
import com.yolo.keyborad.model.vo.googleplay.GooglePlayPurchaseVerifyResp;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
|
||||
public interface GooglePlayBillingService {
|
||||
|
||||
GooglePlayPurchaseVerifyResp verifyPurchase(Long userId, GooglePlayPurchaseVerifyReq req);
|
||||
|
||||
void handleRtdn(HttpServletRequest request, GooglePlayPubSubPushRequest pushRequest);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.yolo.keyborad.model.dto.comment.CommentReportReq;
|
||||
import com.yolo.keyborad.model.entity.KeyboardAiCommentReport;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/20
|
||||
*/
|
||||
public interface KeyboardAiCommentReportService extends IService<KeyboardAiCommentReport> {
|
||||
|
||||
/**
|
||||
* 举报评论
|
||||
*
|
||||
* @param userId 用户ID
|
||||
* @param req 举报请求
|
||||
* @return 举报记录ID
|
||||
*/
|
||||
Long reportComment(Long userId, CommentReportReq req);
|
||||
}
|
||||
@@ -23,6 +23,14 @@ public interface KeyboardAiCompanionCommentService extends IService<KeyboardAiCo
|
||||
*/
|
||||
Long addComment(Long userId, Long companionId, String content, Long parentId, Long rootId);
|
||||
|
||||
/**
|
||||
* 删除评论
|
||||
*
|
||||
* @param userId 当前用户ID
|
||||
* @param commentId 评论ID
|
||||
*/
|
||||
void deleteComment(Long userId, Long commentId);
|
||||
|
||||
/**
|
||||
* 分页查询评论
|
||||
*
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.yolo.keyborad.model.entity.KeyboardAiCompanionI18n;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/2 09:35
|
||||
*/
|
||||
|
||||
public interface KeyboardAiCompanionI18nService extends IService<KeyboardAiCompanionI18n>{
|
||||
|
||||
|
||||
}
|
||||
@@ -20,7 +20,11 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
|
||||
* @param pageSize 每页数量
|
||||
* @return 分页结果
|
||||
*/
|
||||
IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize);
|
||||
IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize, String acceptLanguage);
|
||||
|
||||
default IPage<AiCompanionVO> pageList(Integer pageNum, Integer pageSize) {
|
||||
return pageList(pageNum, pageSize, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 分页查询已上线的AI陪聊角色(带当前用户点赞状态)
|
||||
@@ -30,7 +34,11 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
|
||||
* @param pageSize 每页数量
|
||||
* @return 分页结果
|
||||
*/
|
||||
IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize);
|
||||
IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize, String acceptLanguage);
|
||||
|
||||
default IPage<AiCompanionVO> pageListWithLikeStatus(Long userId, Integer pageNum, Integer pageSize) {
|
||||
return pageListWithLikeStatus(userId, pageNum, pageSize, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据AI人设ID获取系统提示词
|
||||
@@ -46,7 +54,11 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
|
||||
* @param userId 用户ID
|
||||
* @return 点赞过的AI角色列表
|
||||
*/
|
||||
List<AiCompanionVO> getLikedCompanions(Long userId);
|
||||
List<AiCompanionVO> getLikedCompanions(Long userId, String acceptLanguage);
|
||||
|
||||
default List<AiCompanionVO> getLikedCompanions(Long userId) {
|
||||
return getLikedCompanions(userId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户聊过天的AI角色列表
|
||||
@@ -54,7 +66,11 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
|
||||
* @param userId 用户ID
|
||||
* @return 聊过天的AI角色列表
|
||||
*/
|
||||
List<AiCompanionVO> getChattedCompanions(Long userId);
|
||||
List<AiCompanionVO> getChattedCompanions(Long userId, String acceptLanguage);
|
||||
|
||||
default List<AiCompanionVO> getChattedCompanions(Long userId) {
|
||||
return getChattedCompanions(userId, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据ID获取AI角色详情(带点赞数、评论数和当前用户点赞状态)
|
||||
@@ -63,5 +79,9 @@ public interface KeyboardAiCompanionService extends IService<KeyboardAiCompanion
|
||||
* @param companionId AI角色ID
|
||||
* @return AI角色详情
|
||||
*/
|
||||
AiCompanionVO getCompanionById(Long userId, Long companionId);
|
||||
AiCompanionVO getCompanionById(Long userId, Long companionId, String acceptLanguage);
|
||||
|
||||
default AiCompanionVO getCompanionById(Long userId, Long companionId) {
|
||||
return getCompanionById(userId, companionId, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.yolo.keyborad.model.entity.KeyboardAppVersions;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.yolo.keyborad.model.dto.appversion.KeyboardAppUpdateCheckReq;
|
||||
import com.yolo.keyborad.model.vo.appversion.KeyboardAppUpdateCheckRespVO;
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/4 16:36
|
||||
*/
|
||||
|
||||
public interface KeyboardAppVersionsService extends IService<KeyboardAppVersions>{
|
||||
|
||||
/**
|
||||
* 检查客户端是否需要更新/强制更新。
|
||||
*/
|
||||
KeyboardAppUpdateCheckRespVO checkUpdate(KeyboardAppUpdateCheckReq req);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.yolo.keyborad.model.entity.KeyboardCharacterI18n;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/1 14:13
|
||||
*/
|
||||
|
||||
public interface KeyboardCharacterI18nService extends IService<KeyboardCharacterI18n>{
|
||||
|
||||
|
||||
}
|
||||
@@ -18,11 +18,11 @@ import java.util.List;
|
||||
public interface KeyboardCharacterService extends IService<KeyboardCharacter>{
|
||||
|
||||
|
||||
List<KeyboardCharacterRespVO> selectListWithRank();
|
||||
List<KeyboardCharacterRespVO> selectListWithRank(String acceptLanguage);
|
||||
|
||||
List<KeyboardCharacterRespVO> selectListByTag(Long tagId);
|
||||
List<KeyboardCharacterRespVO> selectListByTag(Long tagId, String acceptLanguage);
|
||||
|
||||
List<KeyboardUserCharacterVO> selectListByUserId();
|
||||
List<KeyboardUserCharacterVO> selectListByUserId(String acceptLanguage);
|
||||
|
||||
void updateSort(KeyboardUserCharacterSortUpdateDTO sortUpdateDTO);
|
||||
|
||||
@@ -30,11 +30,11 @@ public interface KeyboardCharacterService extends IService<KeyboardCharacter>{
|
||||
|
||||
void removeUserCharacter(Long id);
|
||||
|
||||
List<KeyboardCharacterRespVO> selectListWithNotLoginRank();
|
||||
List<KeyboardCharacterRespVO> selectListWithNotLoginRank(String acceptLanguage);
|
||||
|
||||
List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId);
|
||||
List<KeyboardCharacterRespVO> selectListByTagWithNotLogin(Long tagId, String acceptLanguage);
|
||||
|
||||
void addDefaultUserCharacter(Long userId);
|
||||
|
||||
KeyboardCharacterRespVO getDetailById(Long id);
|
||||
KeyboardCharacterRespVO getDetailById(Long id, String acceptLanguage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
import com.yolo.keyborad.model.entity.KeyboardCommentBlockRelation;
|
||||
import com.yolo.keyborad.model.vo.CommentBlockedUserVO;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/3/23 10:30
|
||||
*/
|
||||
|
||||
public interface KeyboardCommentBlockRelationService extends IService<KeyboardCommentBlockRelation> {
|
||||
|
||||
void blockUser(Long blockerUserId, Long blockedUserId);
|
||||
|
||||
void unblockUser(Long blockerUserId, Long blockedUserId);
|
||||
|
||||
List<CommentBlockedUserVO> listBlockedUsers(Long blockerUserId);
|
||||
|
||||
List<Long> listBlockedUserIds(Long blockerUserId);
|
||||
}
|
||||
@@ -12,7 +12,7 @@ import java.util.List;
|
||||
public interface KeyboardProductItemsService extends IService<KeyboardProductItems>{
|
||||
|
||||
/**
|
||||
* 根据主键ID查询商品明细
|
||||
* 根据主键ID和平台查询商品明细
|
||||
*
|
||||
* @param id 商品主键ID
|
||||
* @return 商品明细(不存在返回 null)
|
||||
@@ -20,7 +20,7 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
|
||||
KeyboardProductItemRespVO getProductDetailById(Long id);
|
||||
|
||||
/**
|
||||
* 根据 Apple productId 查询商品明细
|
||||
* 根据 productId 和平台查询商品明细
|
||||
*
|
||||
* @param productId 商品 productId
|
||||
* @return 商品明细(不存在返回 null)
|
||||
@@ -39,6 +39,6 @@ public interface KeyboardProductItemsService extends IService<KeyboardProductIte
|
||||
* @param type 商品类型:subscription / in-app-purchase / all
|
||||
* @return 商品列表
|
||||
*/
|
||||
List<KeyboardProductItemRespVO> listProductsByType(String type);
|
||||
List<KeyboardProductItemRespVO> listProductsByType(String type, String platform);
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
package com.yolo.keyborad.service;
|
||||
|
||||
import com.yolo.keyborad.model.entity.KeyboardTagI18n;
|
||||
import com.baomidou.mybatisplus.extension.service.IService;
|
||||
/*
|
||||
* @author: ziin
|
||||
* @date: 2026/4/2 13:54
|
||||
*/
|
||||
|
||||
public interface KeyboardTagI18nService extends IService<KeyboardTagI18n>{
|
||||
|
||||
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user