You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何基于Codename One实现带Promotional Offers的自动续订订阅?

在Codename One中实现Apple Promotional Offers

Codename One的默认Purchase API并未封装Apple Promotional Offers的密钥处理逻辑,要实现这个功能,需要通过原生代码扩展来完成,以下是具体步骤:

1. 准备Apple侧必要资源

  • 已在App Store Connect创建并激活目标Promotional Offer,获取对应的:产品ID、优惠ID、密钥ID、PEM格式私钥
  • 明确Apple签名规则:签名基于ECDSA-SHA256算法,payload由产品ID + 优惠ID + 随机nonce + 秒级时间戳拼接而成

2. 扩展Codename One的Native Interface

首先定义一个原生接口,用于桥接Codename One代码和iOS原生逻辑:

public interface PromotionalOfferNative extends NativeInterface {
    // 生成Promotional Offer所需的签名
    String generateOfferSignature(String productId, String offerId, String keyId, String pemPrivateKey, String nonce, String timestamp);
    // 发起带优惠的订阅购买
    void startPromotionalPurchase(String productId, String offerId, String signature, String nonce, String timestamp);
}

接着实现iOS端的Objective-C原生代码:

#import <StoreKit/StoreKit.h>
#import "CodenameOne_GLViewController.h"
#import <Security/Security.h>

@implementation PromotionalOfferNativeImpl

- (NSString*)generateOfferSignature:(NSString*)productId offerId:(NSString*)offerId keyId:(NSString*)keyId pemPrivateKey:(NSString*)pemKey nonce:(NSString*)nonce timestamp:(NSString*)timestamp {
    // 拼接签名Payload
    NSString* payload = [NSString stringWithFormat:@"%@%@%@%@", productId, offerId, nonce, timestamp];
    NSData* payloadData = [payload dataUsingEncoding:NSUTF8StringEncoding];
    
    // 转换PEM私钥为SecKeyRef
    SecKeyRef privateKey = [self secKeyFromPEM:pemKey];
    if (!privateKey) return nil;
    
    // 生成ECDSA-SHA256签名
    uint8_t sigBytes[256];
    size_t sigLength = sizeof(sigBytes);
    OSStatus status = SecKeyRawSign(privateKey, kSecPaddingPKCS1SHA256, payloadData.bytes, payloadData.length, sigBytes, &sigLength);
    
    if (status != errSecSuccess) return nil;
    NSData* sigData = [NSData dataWithBytes:sigBytes length:sigLength];
    return [sigData base64EncodedStringWithOptions:0];
}

- (void)startPromotionalPurchase:(NSString*)productId offerId:(NSString*)offerId signature:(NSString*)signature nonce:(NSString*)nonce timestamp:(NSString*)timestamp {
    // 查找对应SKProduct
    SKProduct* targetProduct = nil;
    for (SKProduct* product in [SKPaymentQueue defaultQueue].products) {
        if ([product.productIdentifier isEqualToString:productId]) {
            targetProduct = product;
            break;
        }
    }
    if (!targetProduct) return;
    
    // 创建优惠对象
    SKPaymentDiscount* discount = [[SKPaymentDiscount alloc] initWithIdentifier:offerId
                                                                     keyIdentifier:keyId
                                                                              nonce:nonce
                                                                           signature:signature
                                                                           timestamp:timestamp];
    // 创建带优惠的支付请求
    SKMutablePayment* payment = [SKMutablePayment paymentWithProduct:targetProduct];
    payment.paymentDiscount = discount;
    
    // 发起支付
    [[SKPaymentQueue defaultQueue] addPayment:payment];
}

// 辅助方法:将PEM格式私钥转为SecKeyRef
- (SecKeyRef)secKeyFromPEM:(NSString*)pemString {
    // 移除PEM头尾标记和换行
    NSString* cleanedKey = [pemString stringByReplacingOccurrencesOfString:@"-----BEGIN PRIVATE KEY-----" withString:@""];
    cleanedKey = [cleanedKey stringByReplacingOccurrencesOfString:@"-----END PRIVATE KEY-----" withString:@""];
    cleanedKey = [cleanedKey stringByReplacingOccurrencesOfString:@"\n" withString:@""];
    
    NSData* keyData = [[NSData alloc] initWithBase64EncodedString:cleanedKey options:NSDataBase64DecodingIgnoreUnknownCharacters];
    if (!keyData) return nil;
    
    NSDictionary* keyAttrs = @{
        (__bridge id)kSecAttrKeyType: (__bridge id)kSecAttrKeyTypeEC,
        (__bridge id)kSecAttrKeyClass: (__bridge id)kSecAttrKeyClassPrivate,
        (__bridge id)kSecAttrKeySizeInBits: @256
    };
    
    CFErrorRef error = NULL;
    SecKeyRef keyRef = SecKeyCreateWithData((__bridge CFDataRef)keyData, (__bridge CFDictionaryRef)keyAttrs, &error);
    if (error) {
        CFRelease(error);
        return nil;
    }
    return keyRef;
}

@end

3. 在Codename One主代码中调用

// 检查是否为iOS平台且原生接口可用
PromotionalOfferNative offerNative = NativeLookup.create(PromotionalOfferNative.class);
if (offerNative != null && offerNative.isSupported()) {
    // 生成随机nonce和秒级时间戳
    String nonce = UUID.randomUUID().toString().replace("-", "");
    String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
    
    // 生成签名(生产环境建议由后端生成签名,避免私钥暴露在客户端)
    String signature = offerNative.generateOfferSignature(
        "com.your.app.subscription",  // 产品ID
        "your_offer_id",              // 优惠ID
        "your_key_id",                // 密钥ID
        "-----BEGIN PRIVATE KEY-----\n...\n-----END PRIVATE KEY-----",  // PEM私钥
        nonce,
        timestamp
    );
    
    // 发起带优惠的购买
    if (signature != null) {
        offerNative.startPromotionalPurchase(
            "com.your.app.subscription",
            "your_offer_id",
            signature,
            nonce,
            timestamp
        );
    }
}

4. 处理购买回调

保持原有Codename One的PurchaseCallback逻辑即可,Promotional Offers的购买完成后会触发相同的itemPurchased、purchaseError等回调事件,正常处理订阅状态更新即可。

关键注意事项

  • 私钥安全:绝对不要在客户端代码中硬编码私钥,生产环境必须由后端服务生成签名,客户端仅获取签名后发起购买
  • 签名准确性:必须严格按照Apple的规则拼接payload,使用ECDSA-SHA256算法,否则会导致优惠验证失败
  • 测试验证:在Sandbox环境测试时,使用Apple提供的测试账号,确保Promotional Offer已在App Store Connect中激活

内容的提问来源于stack exchange,提问作者Renz

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.24 16:40:04