Files
plates-server/src/users/services/apple-purchase.service.ts
2025-08-13 15:17:33 +08:00

494 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs';
import * as jwt from 'jsonwebtoken';
import axios from 'axios';
@Injectable()
export class ApplePurchaseService {
private readonly logger = new Logger(ApplePurchaseService.name);
private readonly appSharedSecret: string;
private readonly keyId: string;
private readonly issuerId: string;
private readonly bundleId: string;
private readonly privateKeyPath: string;
private privateKey: string;
constructor(private configService: ConfigService) {
this.appSharedSecret = this.configService.get<string>('APPLE_APP_SHARED_SECRET') || '';
this.keyId = this.configService.get<string>('APPLE_KEY_ID') || '';
this.issuerId = this.configService.get<string>('APPLE_ISSUER_ID') || '';
this.bundleId = this.configService.get<string>('APPLE_BUNDLE_ID') || '';
this.privateKeyPath = this.configService.get<string>('APPLE_PRIVATE_KEY_PATH') || '';
try {
// 读取私钥文件
this.privateKey = fs.readFileSync(this.privateKeyPath, 'utf8');
} catch (error) {
this.logger.error(`无法读取Apple私钥文件: ${error.message}`);
this.privateKey = '';
}
}
/**
* 使用共享密钥验证收据旧方法适用于StoreKit 1
*/
async verifyReceiptWithSharedSecret(receipt: string, isSandbox = false): Promise<any> {
try {
// 苹果验证URL
const productionUrl = 'https://buy.itunes.apple.com/verifyReceipt';
const sandboxUrl = 'https://sandbox.itunes.apple.com/verifyReceipt';
const verifyUrl = isSandbox ? sandboxUrl : productionUrl;
this.logger.log(`验证receipt: ${receipt}`);
// 发送验证请求
const response = await axios.post(verifyUrl, {
'receipt-data': receipt,
'password': this.appSharedSecret,
});
// 如果状态码为21007说明是沙盒收据需要在沙盒环境验证
if (response.data.status === 21007 && !isSandbox) {
return this.verifyReceiptWithSharedSecret(receipt, true);
}
this.logger.log(`Apple验证响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`Apple验证失败: ${error.message}`);
throw error;
}
}
/**
* 生成用于App Store Connect API的JWT令牌
*/
private generateToken(): string {
try {
if (!this.privateKey) {
throw new Error('私钥未加载,无法生成令牌');
}
const now = Math.floor(Date.now() / 1000);
const payload = {
iss: this.issuerId, // 发行者ID
iat: now, // 签发时间
exp: now + 3600, // 过期时间1小时后
aud: 'appstoreconnect-v1' // 受众,固定值
};
const signOptions = {
algorithm: 'ES256', // 要求使用ES256算法
header: {
alg: 'ES256',
kid: this.keyId, // 密钥ID
typ: 'JWT'
}
};
return jwt.sign(payload, this.privateKey, signOptions as jwt.SignOptions);
} catch (error) {
this.logger.error(`生成JWT令牌失败: ${error.message}`);
throw error;
}
}
/**
* 使用StoreKit 2 API验证交易新方法
*/
async verifyTransactionWithJWT(transactionId: string): Promise<any> {
try {
const token = this.generateToken();
// App Store Server API请求
const url = `https://api.storekit.itunes.apple.com/inApps/v1/transactions/${transactionId}`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
this.logger.debug(`交易验证响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`交易验证失败: ${error.message}`);
throw error;
}
}
/**
* 查询订阅状态
*/
async getSubscriptionStatus(originalTransactionId: string): Promise<any> {
try {
const token = this.generateToken();
// 查询订阅状态API
const url = `https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/${originalTransactionId}`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
this.logger.debug(`订阅状态响应: ${JSON.stringify(response.data)}`);
return response.data;
} catch (error) {
this.logger.error(`获取订阅状态失败: ${error.message}`);
throw error;
}
}
/**
* 验证收据
* 综合使用旧API和新API进行验证
*/
async verifyPurchase(receipt: string, transactionId: string): Promise<boolean> {
try {
// 首先使用共享密钥验证收据
const receiptVerification = await this.verifyReceiptWithSharedSecret(receipt, false);
if (receiptVerification.status !== 0) {
this.logger.warn(`收据验证失败,状态码: ${receiptVerification.status}`);
return false;
}
// 如果提供了transactionId则使用JWT验证交易
if (transactionId && this.privateKey) {
try {
const transactionVerification = await this.verifyTransactionWithJWT(transactionId);
// 验证bundleId
if (transactionVerification.bundleId !== this.bundleId) {
this.logger.warn(`交易绑定的bundleId不匹配: ${transactionVerification.bundleId} !== ${this.bundleId}`);
return false;
}
// 验证交易状态
if (transactionVerification.signedTransactionInfo?.transactionState !== 1) {
this.logger.warn(`交易状态无效: ${transactionVerification.signedTransactionInfo?.transactionState}`);
return false;
}
return true;
} catch (error) {
this.logger.error(`JWT交易验证失败: ${error.message}`);
// 如果JWT验证失败但收据验证成功仍然返回true
return true;
}
}
return true;
} catch (error) {
this.logger.error(`验证购买失败: ${error.message}`);
return false;
}
}
/**
* 处理App Store服务器通知
* 用于处理苹果服务器发送的通知事件
*/
async processServerNotification(signedPayload: string): Promise<any> {
try {
this.logger.log(`接收到App Store服务器通知: ${signedPayload.substring(0, 100)}...`);
// 解码JWS格式的负载
const notificationPayload = this.decodeJWS<{
// SUBSCRIBED
notificationType: string;
subtype: string;
notificationUUID: string;
data: {
appAppleId: number;
bundleId: string;
environment: string;
bundleVersion: string;
signedTransactionInfo: string;
signedRenewalInfo: string;
status: number;
};
// 时间戳
signedDate: number
}>(signedPayload);
if (!notificationPayload) {
throw new Error('无法解码通知负载');
}
this.logger.log(`解码后的通知负载: ${JSON.stringify(notificationPayload, null, 2)}`);
// 验证通知的合法性
if (!this.validateNotification(notificationPayload)) {
throw new Error('通知验证失败');
}
// 解码交易信息
let transactionInfo = null;
if (notificationPayload.data?.signedTransactionInfo) {
transactionInfo = this.decodeJWS(notificationPayload.data.signedTransactionInfo);
}
// 解码续订信息
let renewalInfo = null;
if (notificationPayload.data?.signedRenewalInfo) {
renewalInfo = this.decodeJWS(notificationPayload.data.signedRenewalInfo);
}
return {
notificationPayload,
transactionInfo,
renewalInfo,
};
} catch (error) {
this.logger.error(`处理App Store服务器通知失败: ${error.message}`);
throw error;
}
}
/**
* 解码JWS格式的数据
*/
private decodeJWS<T>(jwsString: string): T | null {
try {
// JWS格式: header.payload.signature
const parts = jwsString.split('.');
if (parts.length !== 3) {
throw new Error('无效的JWS格式');
}
// 解码payload部分Base64URL编码
const payload = parts[1];
const decodedPayload = Buffer.from(payload, 'base64url').toString('utf8');
return JSON.parse(decodedPayload);
} catch (error) {
this.logger.error(`解码JWS失败: ${error.message}`);
return null;
}
}
/**
* 验证通知的合法性
*/
private validateNotification(notificationPayload: any): boolean {
try {
// 验证必要字段
if (!notificationPayload.notificationType) {
this.logger.warn('通知缺少notificationType字段');
return false;
}
if (!notificationPayload.data?.bundleId) {
this.logger.warn('通知缺少bundleId字段');
return false;
}
// 验证bundleId是否匹配
if (notificationPayload.data.bundleId !== this.bundleId) {
this.logger.warn(`bundleId不匹配: ${notificationPayload.data.bundleId} !== ${this.bundleId}`);
return false;
}
return true;
} catch (error) {
this.logger.error(`验证通知失败: ${error.message}`);
return false;
}
}
/**
* 根据通知类型处理不同的业务逻辑
*/
async handleNotificationByType(notificationType: string, transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log(`处理通知类型: ${notificationType}`);
switch (notificationType) {
case 'SUBSCRIBED':
return this.handleSubscribed(transactionInfo, renewalInfo);
case 'DID_RENEW':
return this.handleDidRenew(transactionInfo, renewalInfo);
case 'EXPIRED':
return this.handleExpired(transactionInfo, renewalInfo);
case 'DID_FAIL_TO_RENEW':
return this.handleDidFailToRenew(transactionInfo, renewalInfo);
case 'DID_CHANGE_RENEWAL_STATUS':
return this.handleDidChangeRenewalStatus(transactionInfo, renewalInfo);
case 'REFUND':
return this.handleRefund(transactionInfo, renewalInfo);
case 'REVOKE':
return this.handleRevoke(transactionInfo, renewalInfo);
case 'TEST':
return this.handleTest(transactionInfo, renewalInfo);
default:
this.logger.warn(`未处理的通知类型: ${notificationType}`);
return { handled: false, notificationType };
}
}
/**
* 处理订阅通知
*/
private async handleSubscribed(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理订阅通知', transactionInfo, renewalInfo);
// 这里可以实现具体的订阅处理逻辑
// 例如:更新用户的订阅状态、发送欢迎邮件等
return { handled: true, action: 'subscribed' };
}
/**
* 处理续订通知
*/
private async handleDidRenew(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订通知');
// 这里可以实现具体的续订处理逻辑
// 例如:延长用户的订阅期限
return { handled: true, action: 'renewed' };
}
/**
* 处理过期通知
*/
private async handleExpired(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理过期通知');
// 这里可以实现具体的过期处理逻辑
// 例如:停止用户的订阅服务
return { handled: true, action: 'expired' };
}
/**
* 处理续订失败通知
*/
private async handleDidFailToRenew(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订失败通知');
// 这里可以实现具体的续订失败处理逻辑
// 例如:发送付款提醒邮件
return { handled: true, action: 'renewal_failed' };
}
/**
* 处理续订状态变更通知
*/
private async handleDidChangeRenewalStatus(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理续订状态变更通知');
// 这里可以实现具体的续订状态变更处理逻辑
return { handled: true, action: 'renewal_status_changed' };
}
/**
* 处理退款通知
*/
private async handleRefund(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理退款通知');
// 这里可以实现具体的退款处理逻辑
// 例如:撤销用户的订阅权限
return { handled: true, action: 'refunded' };
}
/**
* 处理撤销通知
*/
private async handleRevoke(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理撤销通知');
// 这里可以实现具体的撤销处理逻辑
return { handled: true, action: 'revoked' };
}
/**
* 处理测试通知
*/
private async handleTest(transactionInfo: any, renewalInfo: any): Promise<any> {
this.logger.log('处理测试通知');
return { handled: true, action: 'test' };
}
async handleServerNotification(payload: any): Promise<any> {
try {
this.logger.log(`收到Apple服务器通知: ${JSON.stringify(payload)}`);
// 验证通知签名(生产环境应该实现)
// ...
const notificationType = payload.notificationType;
const subtype = payload.subtype;
const data = payload.data;
// 处理不同类型的通知
switch (notificationType) {
case 'CONSUMPTION_REQUEST':
// 消费请求,用于非消耗型项目
break;
case 'DID_CHANGE_RENEWAL_PREF':
// 订阅续订偏好改变
break;
case 'DID_CHANGE_RENEWAL_STATUS':
// 订阅续订状态改变
break;
case 'DID_FAIL_TO_RENEW':
// 订阅续订失败
break;
case 'DID_RENEW':
// 订阅已续订
break;
case 'EXPIRED':
// 订阅已过期
break;
case 'GRACE_PERIOD_EXPIRED':
// 宽限期已过期
break;
case 'OFFER_REDEEMED':
// 优惠已兑换
break;
case 'PRICE_INCREASE':
// 价格上涨
break;
case 'REFUND':
// 退款
break;
case 'REVOKE':
// 撤销
break;
default:
this.logger.warn(`未知的通知类型: ${notificationType}`);
}
return {
success: true
};
} catch (error) {
this.logger.error(`处理服务器通知失败: ${error.message}`);
throw error;
}
}
//从apple获取用户的订阅历史参考 storekit2 的示例
async getAppleSubscriptionHistory(userId: string): Promise<any> {
const token = this.generateToken();
const url = `https://api.storekit.itunes.apple.com/inApps/v1/subscriptions/${userId}/history`;
const response = await axios.get(url, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
return response.data;
}
}