feat(支付): 新增Apple IAP支付支持
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
Some checks failed
Build docker and publish / build (20.15.1) (push) Has been cancelled
实现Apple应用内购支付功能,包括: 1. 新增AppleIAP和ApplePay支付平台枚举 2. 添加IAP验证接口/v1/public/iap/verify处理初购验证 3. 实现Apple服务器通知处理逻辑/v1/iap/notifications 4. 新增JWS验签和JWKS公钥缓存功能 5. 复用现有订单系统处理IAP支付订单 相关文档已更新,包含接入方案和实现细节
This commit is contained in:
parent
4b6fcb338e
commit
d95911d6bd
47
.trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md
Normal file
47
.trae/documents/接入 Apple 自动续期并复用现有支付体系 (1).md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
## 实施目标
|
||||||
|
- 复用现有订单与队列赋权,接入 Apple 自动续期(IAP),保持报表/审计/通知一致。
|
||||||
|
|
||||||
|
## 方案选择
|
||||||
|
- 采用“平台化复用 + 合成订单”的方式:Apple 由客户端结算 + 服务器通知驱动,服务端生成“已支付订单”进入现有赋权与续费流程。
|
||||||
|
|
||||||
|
## 具体改动(按文件)
|
||||||
|
1) 平台标识
|
||||||
|
- 更新 `pkg/payment/platform.go`:新增 `AppleIAP` 枚举与名称(仅标识,不参与 `PurchaseCheckout`)。
|
||||||
|
|
||||||
|
2) 路由与 Handler
|
||||||
|
- 新增公共接口:`POST /v1/public/iap/verify`
|
||||||
|
- 位置:`internal/handler/public/iap/verifyHandler.go`
|
||||||
|
- 逻辑:调用 `internal/logic/public/iap/verifyLogic.go`,以 `originalTransactionId` 验证 Apple 购买,生成“已支付订阅订单”,入队激活。
|
||||||
|
- 新增通知接口:`POST /v1/iap/notifications`
|
||||||
|
- 位置:`internal/handler/notify/appleIAPNotifyHandler.go`
|
||||||
|
- 逻辑:调用 `internal/logic/notify/appleIAPNotifyLogic.go`,JWS 验签后按事件(初购/续期/退款)生成或更新订单,触发续费或撤销权益。
|
||||||
|
- 路由注册:
|
||||||
|
- `internal/handler/routes.go` 增加 `/v1/public/iap/verify` 路由。
|
||||||
|
- `internal/handler/notify.go` 增加独立 `/v1/iap/notifications` 路由(Apple 不带 `:token`)。
|
||||||
|
|
||||||
|
3) 数据与模型
|
||||||
|
- 在用户订阅(或新建 `iap_binding` 表)绑定:`originalTransactionId`、`environment`、`latestExpiresDate`。
|
||||||
|
- 订单字段复用:`Method=AppleIAP`、`TradeNo=originalTransactionId`、`Type=1/2`(订阅/续费),`Status=2`(已支付),金额可取通知中的价格;取不到则置 `Amount=0` 保证流程。
|
||||||
|
|
||||||
|
4) 逻辑复用与改造点
|
||||||
|
- 赋权:复用 `queue/logic/order/activateOrderLogic.go:165 NewPurchase`。
|
||||||
|
- 续费:复用 `queue/logic/order/activateOrderLogic.go:529 updateSubscriptionForRenewal`。
|
||||||
|
- 不改动 `internal/logic/public/portal/purchaseCheckoutLogic.go` 的渠道路由(Apple 不走此流程)。
|
||||||
|
|
||||||
|
5) 安全与幂等
|
||||||
|
- Apple JWS 验签:拉取并缓存 JWKS 公钥,校验通知;拒绝无效签名。
|
||||||
|
- 幂等:以 `notificationId`/`transactionId` 与 `originalTransactionId` 去重处理。
|
||||||
|
|
||||||
|
6) 客户端协作
|
||||||
|
- iOS:完成 StoreKit 购买后携带 `originalTransactionId` 调用 `/v1/public/iap/verify`。
|
||||||
|
- 续费:依赖 Server Notifications v2 自动驱动,无需客户端调用。
|
||||||
|
|
||||||
|
7) 测试与监控
|
||||||
|
- 沙盒验证初购、续期、重试与宽限期、退款撤销;注意元数据延迟(~1小时)。
|
||||||
|
- 指标:通知验签失败、API 调用失败、幂等冲突、状态不一致报警。
|
||||||
|
|
||||||
|
## 交付节奏
|
||||||
|
- 第一步:平台枚举与路由骨架;
|
||||||
|
- 第二步:`verify` 验证与“合成订单”生成;
|
||||||
|
- 第三步:通知验签与事件映射;
|
||||||
|
- 第四步:沙盒联调,确认队列赋权与续费延长。
|
||||||
92
.trae/documents/接入 Apple 自动续期并复用现有支付体系.md
Normal file
92
.trae/documents/接入 Apple 自动续期并复用现有支付体系.md
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
## 结论
|
||||||
|
|
||||||
|
* 可以复用你现有的“订单→支付成功→订单激活(赋权)→通知/返佣”的主干流程,但“支付环节”不能复用第三方网关逻辑,必须改为 Apple IAP 的校验与事件驱动。
|
||||||
|
|
||||||
|
* 复用范围:订单模型、续费与赋权队列、优惠/手续费计算、通知与返佣;差异点:支付下单与回调换成“StoreKit 客户端购买 + 服务端向 Apple 校验 + Apple Server Notifications v2”。
|
||||||
|
|
||||||
|
## 可复用的部分
|
||||||
|
|
||||||
|
1. 订单激活与赋权
|
||||||
|
|
||||||
|
* 新购赋权:`queue/logic/order/activateOrderLogic.go:164-193` 的 `NewPurchase`
|
||||||
|
|
||||||
|
* 续费赋权:`queue/logic/order/activateOrderLogic.go:473-515` 的 `Renewal`
|
||||||
|
|
||||||
|
* 流量重置与充值:`queue/logic/order/activateOrderLogic.go:564-626`, `630-675`
|
||||||
|
|
||||||
|
1. 订单与费用模型
|
||||||
|
|
||||||
|
* 订单结构:`internal/model/order/order.go:5-29` 可继续承载 IAP 订单(新增字段映射 Apple 交易)
|
||||||
|
|
||||||
|
* 费用/折扣/礼金计算逻辑保持不变
|
||||||
|
|
||||||
|
1. 队列驱动
|
||||||
|
|
||||||
|
* 仍使用“支付成功→入队→处理”的模式:`queue/logic/order/activateOrderLogic.go:65-86`
|
||||||
|
|
||||||
|
## 必须独立实现的部分
|
||||||
|
|
||||||
|
1. Apple IAP 支付与校验
|
||||||
|
|
||||||
|
* 客户端使用 StoreKit 购买,拿到 `originalTransactionId`
|
||||||
|
|
||||||
|
* 服务端调用 App Store Server API,基于 `originalTransactionId` 校验订阅有效性并取交易历史
|
||||||
|
|
||||||
|
1. Apple Server Notifications v2
|
||||||
|
|
||||||
|
* 在 App Store Connect 配置通知 URL
|
||||||
|
|
||||||
|
* 服务端实现 JWS 验签,解析事件并落库:续期、失败、宽限期、退款、撤销等
|
||||||
|
|
||||||
|
## 整合方式(复用策略)
|
||||||
|
|
||||||
|
1. 引入平台枚举“AppleIAP”
|
||||||
|
|
||||||
|
* 在 `pkg/payment/platform.go` 增加 `AppleIAP`,用于平台标识与管理端展示
|
||||||
|
|
||||||
|
1. 订单创建策略(两种)
|
||||||
|
|
||||||
|
* 方案 A(推荐):用户在 iOS 内购完成后由客户端上报 `originalTransactionId`,服务端校验通过后“合成一个已支付订单”(`status=2`),触发既有赋权队列
|
||||||
|
|
||||||
|
* 方案 B:也可预建“待支付订单”,但 `PurchaseCheckout` 不走网关,只返回“client\_iap”类型,提示客户端用 StoreKit;支付完成后再校验并更新为 `Paid` 入队
|
||||||
|
|
||||||
|
1. 状态与权益判定
|
||||||
|
|
||||||
|
* 服务端统一以 Apple 校验与通知为准,抽象为 `active/in_grace_period/in_billing_retry/expired/revoked` 并映射到你的订阅与订单状态
|
||||||
|
|
||||||
|
## 服务端接口与流程
|
||||||
|
|
||||||
|
* `POST /apple/iap/verify`:入参 `originalTransactionId`,校验并创建/更新订单与用户订阅,返回当前权益
|
||||||
|
|
||||||
|
* `POST /apple/iap/notifications`:接收 Apple JWS 通知,验签后更新订阅与订单状态(幂等)
|
||||||
|
|
||||||
|
* `GET /subscriptions/me`:面向客户端查询当前订阅与权益(聚合 Apple 校验结果)
|
||||||
|
|
||||||
|
## 数据模型映射
|
||||||
|
|
||||||
|
* 在订单/订阅表补充字段(建议):`Provider=apple_iap`、`OriginalTransactionId`、`Environment`、`ExpiresDate`、`AutoRenewStatus`、`InGracePeriod`、`LastEventType`
|
||||||
|
|
||||||
|
* 产品映射:`productId(App Store) ↔ internal subscribeId`,保证同一权益统一计费
|
||||||
|
|
||||||
|
## 回调与安全
|
||||||
|
|
||||||
|
* JWS 验签:缓存 Apple JWKS 公钥、短生命周期缓存
|
||||||
|
|
||||||
|
* App Store Server API:使用 App Store Connect API Key(ES256)发起请求,区分生产/沙盒
|
||||||
|
|
||||||
|
* 幂等:按 `notificationId/transactionId` 去重
|
||||||
|
|
||||||
|
## 测试与上线
|
||||||
|
|
||||||
|
* 沙盒测试:购买、续期、失败、宽限期、退款全链路;注意沙盒元数据生效可能需 \~1 小时
|
||||||
|
|
||||||
|
* 监控:通知处理失败、验签失败、API 调用异常报警
|
||||||
|
|
||||||
|
## 交付物(最小实现)
|
||||||
|
|
||||||
|
1. 平台枚举新增 `AppleIAP`
|
||||||
|
2. `POST /apple/iap/verify` 与 `POST /apple/iap/notifications` 路由与逻辑骨架
|
||||||
|
3. App Store Server API 客户端封装(校验、交易历史、订阅状态)
|
||||||
|
4. 订单合成与入队赋权打通(复用 `OrderStatusPaid` → `ProcessTask`)
|
||||||
|
5. 数据表字段扩展与迁移脚本
|
||||||
|
|
||||||
@ -8,10 +8,15 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func RegisterNotifyHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
func RegisterNotifyHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
||||||
group := router.Group("/v1/notify/")
|
group := router.Group("/v1/notify/")
|
||||||
group.Use(middleware.NotifyMiddleware(serverCtx))
|
group.Use(middleware.NotifyMiddleware(serverCtx))
|
||||||
{
|
{
|
||||||
group.Any("/:platform/:token", notify.PaymentNotifyHandler(serverCtx))
|
group.Any(":platform/:token", notify.PaymentNotifyHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
iap := router.Group("/v1/iap")
|
||||||
|
{
|
||||||
|
iap.POST("/notifications", notify.AppleIAPNotifyHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
20
internal/handler/notify/appleIAPNotifyHandler.go
Normal file
20
internal/handler/notify/appleIAPNotifyHandler.go
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/notify"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppleIAPNotifyHandler 处理 Apple Server Notifications v2
|
||||||
|
// 参数: 原始 HTTP 请求体
|
||||||
|
// 返回: 处理结果(空体 200)
|
||||||
|
func AppleIAPNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
l := notify.NewAppleIAPNotifyLogic(c.Request.Context(), svcCtx)
|
||||||
|
err := l.Handle(c.Request)
|
||||||
|
result.HttpResult(c, gin.H{"success": err == nil}, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -39,7 +39,7 @@ func PaymentNotifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
c.String(http.StatusOK, "%s", "success")
|
c.String(http.StatusOK, "%s", "success")
|
||||||
case payment.Stripe:
|
case payment.Stripe, payment.ApplePay:
|
||||||
l := notify.NewStripeNotifyLogic(c.Request.Context(), svcCtx)
|
l := notify.NewStripeNotifyLogic(c.Request.Context(), svcCtx)
|
||||||
if err := l.StripeNotify(c.Request, c.Writer); err != nil {
|
if err := l.StripeNotify(c.Request, c.Writer); err != nil {
|
||||||
result.HttpResult(c, nil, err)
|
result.HttpResult(c, nil, err)
|
||||||
|
|||||||
29
internal/handler/public/iap/verifyHandler.go
Normal file
29
internal/handler/public/iap/verifyHandler.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package iap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/perfect-panel/server/internal/logic/public/iap"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/result"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyHandler 处理 iOS IAP 初购验证并生成已支付订单
|
||||||
|
// 参数: IAPVerifyRequest
|
||||||
|
// 返回: IAPVerifyResponse
|
||||||
|
func VerifyHandler(svcCtx *svc.ServiceContext) func(c *gin.Context) {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
var req types.IAPVerifyRequest
|
||||||
|
_ = c.ShouldBind(&req)
|
||||||
|
validateErr := svcCtx.Validate(&req)
|
||||||
|
if validateErr != nil {
|
||||||
|
result.ParamErrorResult(c, validateErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
l := iap.NewVerifyLogic(c.Request.Context(), svcCtx)
|
||||||
|
resp, err := l.Verify(&req)
|
||||||
|
result.HttpResult(c, resp, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -26,7 +26,8 @@ import (
|
|||||||
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
|
authOauth "github.com/perfect-panel/server/internal/handler/auth/oauth"
|
||||||
common "github.com/perfect-panel/server/internal/handler/common"
|
common "github.com/perfect-panel/server/internal/handler/common"
|
||||||
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
publicAnnouncement "github.com/perfect-panel/server/internal/handler/public/announcement"
|
||||||
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
|
publicDocument "github.com/perfect-panel/server/internal/handler/public/document"
|
||||||
|
publicIAP "github.com/perfect-panel/server/internal/handler/public/iap"
|
||||||
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
publicOrder "github.com/perfect-panel/server/internal/handler/public/order"
|
||||||
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
publicPayment "github.com/perfect-panel/server/internal/handler/public/payment"
|
||||||
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
publicPortal "github.com/perfect-panel/server/internal/handler/public/portal"
|
||||||
@ -671,7 +672,7 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
publicAnnouncementGroupRouter.GET("/list", publicAnnouncement.QueryAnnouncementHandler(serverCtx))
|
publicAnnouncementGroupRouter.GET("/list", publicAnnouncement.QueryAnnouncementHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
publicDocumentGroupRouter := router.Group("/v1/public/document")
|
publicDocumentGroupRouter := router.Group("/v1/public/document")
|
||||||
publicDocumentGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
publicDocumentGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
{
|
{
|
||||||
@ -680,7 +681,14 @@ func RegisterHandlers(router *gin.Engine, serverCtx *svc.ServiceContext) {
|
|||||||
|
|
||||||
// Get document list
|
// Get document list
|
||||||
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
|
publicDocumentGroupRouter.GET("/list", publicDocument.QueryDocumentListHandler(serverCtx))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publicIAPGroupRouter := router.Group("/v1/public/iap")
|
||||||
|
publicIAPGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|
||||||
|
{
|
||||||
|
publicIAPGroupRouter.POST("/verify", publicIAP.VerifyHandler(serverCtx))
|
||||||
|
}
|
||||||
|
|
||||||
publicOrderGroupRouter := router.Group("/v1/public/order")
|
publicOrderGroupRouter := router.Group("/v1/public/order")
|
||||||
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
publicOrderGroupRouter.Use(middleware.AuthMiddleware(serverCtx), middleware.DeviceMiddleware(serverCtx))
|
||||||
|
|||||||
134
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
134
internal/logic/notify/appleIAPNotifyLogic.go
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
package notify
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/pkg/appleiap"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/payment"
|
||||||
|
queueType "github.com/perfect-panel/server/queue/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AppleIAPNotifyLogic 处理 Apple Server Notifications v2 的逻辑
|
||||||
|
// 功能: 验签与事件解析(此处提供最小骨架),将续期/初购事件转换为订单并入队赋权
|
||||||
|
// 参数: HTTP 请求
|
||||||
|
// 返回: 错误信息
|
||||||
|
type AppleIAPNotifyLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAppleIAPNotifyLogic 创建逻辑实例
|
||||||
|
// 参数: 上下文, 服务上下文
|
||||||
|
// 返回: 逻辑指针
|
||||||
|
func NewAppleIAPNotifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *AppleIAPNotifyLogic {
|
||||||
|
return &AppleIAPNotifyLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppleNotification 简化的通知结构(骨架)
|
||||||
|
type rawPayload struct {
|
||||||
|
SignedPayload string `json:"signedPayload"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type transactionInfo struct {
|
||||||
|
OriginalTransactionId string `json:"originalTransactionId"`
|
||||||
|
TransactionId string `json:"transactionId"`
|
||||||
|
ProductId string `json:"productId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 处理通知
|
||||||
|
// 参数: *http.Request
|
||||||
|
// 返回: error
|
||||||
|
func (l *AppleIAPNotifyLogic) Handle(r *http.Request) error {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var rp rawPayload
|
||||||
|
if err := json.Unmarshal(body, &rp); err != nil {
|
||||||
|
l.Errorw("[AppleIAP] Unmarshal request failed", logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
claims, env, err := appleiap.VerifyAutoEnv(rp.SignedPayload)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[AppleIAP] Verify payload failed", logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
t, _ := claims["notificationType"].(string)
|
||||||
|
data, _ := claims["data"].(map[string]interface{})
|
||||||
|
sti, _ := data["signedTransactionInfo"].(string)
|
||||||
|
txClaims, err := appleiap.VerifyWithEnv(env, sti)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[AppleIAP] Verify transaction failed", logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
b, _ := json.Marshal(txClaims)
|
||||||
|
var tx transactionInfo
|
||||||
|
_ = json.Unmarshal(b, &tx)
|
||||||
|
|
||||||
|
switch t {
|
||||||
|
case "INITIAL_BUY":
|
||||||
|
return l.processInitialBuy(env, tx)
|
||||||
|
case "DID_RENEW":
|
||||||
|
return l.processRenew(env, tx)
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// createPaidOrderAndEnqueue 创建已支付订单并入队赋权/续费
|
||||||
|
// 参数: AppleNotification, 订单类型
|
||||||
|
// 返回: error
|
||||||
|
func (l *AppleIAPNotifyLogic) processInitialBuy(env string, tx transactionInfo) error {
|
||||||
|
if tx.OriginalTransactionId == "" || tx.TransactionId == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
// if order already exists, ignore
|
||||||
|
if oi, err := l.svcCtx.OrderModel.FindOneByTradeNo(l.ctx, tx.OriginalTransactionId); err == nil && oi != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *AppleIAPNotifyLogic) processRenew(env string, tx transactionInfo) error {
|
||||||
|
if tx.OriginalTransactionId == "" || tx.TransactionId == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
oi, err := l.svcCtx.OrderModel.FindOneByTradeNo(l.ctx, tx.OriginalTransactionId)
|
||||||
|
if err != nil || oi == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
o := &order.Order{
|
||||||
|
UserId: oi.UserId,
|
||||||
|
OrderNo: tx.TransactionId,
|
||||||
|
Type: 2,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: 0,
|
||||||
|
Amount: 0,
|
||||||
|
Discount: 0,
|
||||||
|
Coupon: "",
|
||||||
|
CouponDiscount: 0,
|
||||||
|
PaymentId: 0,
|
||||||
|
Method: payment.AppleIAP.String(),
|
||||||
|
FeeAmount: 0,
|
||||||
|
Status: 2,
|
||||||
|
IsNew: false,
|
||||||
|
SubscribeId: oi.SubscribeId,
|
||||||
|
TradeNo: tx.OriginalTransactionId,
|
||||||
|
SubscribeToken: oi.SubscribeToken,
|
||||||
|
}
|
||||||
|
if err := l.svcCtx.OrderModel.Insert(l.ctx, o); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
payload := queueType.ForthwithActivateOrderPayload{OrderNo: o.OrderNo}
|
||||||
|
bytes, _ := json.Marshal(payload)
|
||||||
|
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
|
||||||
|
if _, err := l.svcCtx.Queue.EnqueueContext(l.ctx, task); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
104
internal/logic/public/iap/verifyLogic.go
Normal file
104
internal/logic/public/iap/verifyLogic.go
Normal file
@ -0,0 +1,104 @@
|
|||||||
|
package iap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
"github.com/perfect-panel/server/pkg/payment"
|
||||||
|
"github.com/perfect-panel/server/pkg/tool"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
queueType "github.com/perfect-panel/server/queue/types"
|
||||||
|
"github.com/pkg/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// VerifyLogic 处理 IAP 初购验证并生成已支付订阅订单
|
||||||
|
// 功能: 校验用户与订阅参数, 创建已支付订单并触发赋权队列
|
||||||
|
// 参数: IAPVerifyRequest
|
||||||
|
// 返回: IAPVerifyResponse 与错误
|
||||||
|
type VerifyLogic struct {
|
||||||
|
logger.Logger
|
||||||
|
ctx context.Context
|
||||||
|
svcCtx *svc.ServiceContext
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVerifyLogic 创建 VerifyLogic
|
||||||
|
// 参数: 上下文, 服务上下文
|
||||||
|
// 返回: VerifyLogic 指针
|
||||||
|
func NewVerifyLogic(ctx context.Context, svcCtx *svc.ServiceContext) *VerifyLogic {
|
||||||
|
return &VerifyLogic{Logger: logger.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify 执行 IAP 初购验证并创建订单
|
||||||
|
// 参数: IAPVerifyRequest 包含 original_transaction_id 与 subscribe_id
|
||||||
|
// 返回: IAPVerifyResponse 包含 order_no
|
||||||
|
func (l *VerifyLogic) Verify(req *types.IAPVerifyRequest) (resp *types.IAPVerifyResponse, err error) {
|
||||||
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
|
if !ok {
|
||||||
|
logger.Error("current user is not found in context")
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SubscribeId <= 0 {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid subscribe_id")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub, err := l.svcCtx.SubscribeModel.FindOne(l.ctx, req.SubscribeId)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[IAP Verify] Find subscribe failed", logger.Field("error", err.Error()), logger.Field("subscribe_id", req.SubscribeId))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find subscribe error: %v", err.Error())
|
||||||
|
}
|
||||||
|
if sub.Sell != nil && !*sub.Sell {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "subscribe not sell")
|
||||||
|
}
|
||||||
|
|
||||||
|
isNew, err := l.svcCtx.OrderModel.IsUserEligibleForNewOrder(l.ctx, u.Id)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[IAP Verify] Query user new purchase failed", logger.Field("error", err.Error()), logger.Field("user_id", u.Id))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
orderInfo := &order.Order{
|
||||||
|
UserId: u.Id,
|
||||||
|
OrderNo: tool.GenerateTradeNo(),
|
||||||
|
Type: 1,
|
||||||
|
Quantity: 1,
|
||||||
|
Price: sub.UnitPrice,
|
||||||
|
Amount: 0,
|
||||||
|
Discount: 0,
|
||||||
|
Coupon: "",
|
||||||
|
CouponDiscount: 0,
|
||||||
|
PaymentId: 0,
|
||||||
|
Method: payment.AppleIAP.String(),
|
||||||
|
FeeAmount: 0,
|
||||||
|
Status: 2,
|
||||||
|
IsNew: isNew,
|
||||||
|
SubscribeId: req.SubscribeId,
|
||||||
|
TradeNo: req.OriginalTransactionId,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = l.svcCtx.OrderModel.Insert(l.ctx, orderInfo); err != nil {
|
||||||
|
l.Errorw("[IAP Verify] Insert order failed", logger.Field("error", err.Error()))
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
payload := queueType.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo}
|
||||||
|
bytes, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
l.Errorw("[IAP Verify] Marshal payload failed", logger.Field("error", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
task := asynq.NewTask(queueType.ForthwithActivateOrder, bytes)
|
||||||
|
if _, err = l.svcCtx.Queue.EnqueueContext(l.ctx, task); err != nil {
|
||||||
|
l.Errorw("[IAP Verify] Enqueue activation failed", logger.Field("error", err.Error()))
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &types.IAPVerifyResponse{OrderNo: orderInfo.OrderNo}, nil
|
||||||
|
}
|
||||||
|
|
||||||
@ -86,7 +86,18 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
|
|||||||
|
|
||||||
case paymentPlatform.Stripe:
|
case paymentPlatform.Stripe:
|
||||||
// Process Stripe payment - creates payment sheet for client-side processing
|
// Process Stripe payment - creates payment sheet for client-side processing
|
||||||
stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "")
|
stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "stripePayment error: %v", err.Error())
|
||||||
|
}
|
||||||
|
resp = &types.CheckoutOrderResponse{
|
||||||
|
Type: "stripe", // Client should use Stripe SDK
|
||||||
|
Stripe: stripePayment,
|
||||||
|
}
|
||||||
|
|
||||||
|
case paymentPlatform.ApplePay:
|
||||||
|
// Process Apple Pay payment - uses Stripe with 'card' method
|
||||||
|
stripePayment, err := l.stripePayment(paymentConfig.Config, orderInfo, "", "card")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "stripePayment error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "stripePayment error: %v", err.Error())
|
||||||
}
|
}
|
||||||
@ -208,7 +219,7 @@ func (l *PurchaseCheckoutLogic) alipayF2fPayment(pay *payment.Payment, info *ord
|
|||||||
|
|
||||||
// stripePayment processes Stripe payment by creating a payment sheet
|
// stripePayment processes Stripe payment by creating a payment sheet
|
||||||
// It supports various payment methods including WeChat Pay and Alipay through Stripe
|
// It supports various payment methods including WeChat Pay and Alipay through Stripe
|
||||||
func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order, identifier string) (*types.StripePayment, error) {
|
func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order, identifier string, forceMethod string) (*types.StripePayment, error) {
|
||||||
// Parse Stripe configuration from payment settings
|
// Parse Stripe configuration from payment settings
|
||||||
stripeConfig := &payment.StripeConfig{}
|
stripeConfig := &payment.StripeConfig{}
|
||||||
|
|
||||||
@ -217,6 +228,10 @@ func (l *PurchaseCheckoutLogic) stripePayment(config string, info *order.Order,
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "Unmarshal error: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if forceMethod != "" {
|
||||||
|
stripeConfig.Payment = forceMethod
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize Stripe client with API credentials
|
// Initialize Stripe client with API credentials
|
||||||
client := stripe.NewClient(stripe.Config{
|
client := stripe.NewClient(stripe.Config{
|
||||||
SecretKey: stripeConfig.SecretKey,
|
SecretKey: stripeConfig.SecretKey,
|
||||||
|
|||||||
@ -12,23 +12,25 @@ import (
|
|||||||
|
|
||||||
var _ Model = (*customOrderModel)(nil)
|
var _ Model = (*customOrderModel)(nil)
|
||||||
var (
|
var (
|
||||||
cacheOrderIdPrefix = "cache:order:id:"
|
cacheOrderIdPrefix = "cache:order:id:"
|
||||||
cacheOrderNoPrefix = "cache:order:no:"
|
cacheOrderNoPrefix = "cache:order:no:"
|
||||||
|
cacheOrderTradePrefix = "cache:order:trade:"
|
||||||
)
|
)
|
||||||
|
|
||||||
type (
|
type (
|
||||||
Model interface {
|
Model interface {
|
||||||
orderModel
|
orderModel
|
||||||
customOrderLogicModel
|
customOrderLogicModel
|
||||||
}
|
}
|
||||||
orderModel interface {
|
orderModel interface {
|
||||||
Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error
|
Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error
|
||||||
FindOne(ctx context.Context, id int64) (*Order, error)
|
FindOne(ctx context.Context, id int64) (*Order, error)
|
||||||
FindOneByOrderNo(ctx context.Context, orderNo string) (*Order, error)
|
FindOneByOrderNo(ctx context.Context, orderNo string) (*Order, error)
|
||||||
Update(ctx context.Context, data *Order, tx ...*gorm.DB) error
|
FindOneByTradeNo(ctx context.Context, tradeNo string) (*Order, error)
|
||||||
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
|
Update(ctx context.Context, data *Order, tx ...*gorm.DB) error
|
||||||
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
|
Delete(ctx context.Context, id int64, tx ...*gorm.DB) error
|
||||||
}
|
Transaction(ctx context.Context, fn func(db *gorm.DB) error) error
|
||||||
|
}
|
||||||
|
|
||||||
customOrderModel struct {
|
customOrderModel struct {
|
||||||
*defaultOrderModel
|
*defaultOrderModel
|
||||||
@ -60,12 +62,14 @@ func (m *defaultOrderModel) getCacheKeys(data *Order) []string {
|
|||||||
return []string{}
|
return []string{}
|
||||||
}
|
}
|
||||||
orderIdKey := fmt.Sprintf("%s%v", cacheOrderIdPrefix, data.Id)
|
orderIdKey := fmt.Sprintf("%s%v", cacheOrderIdPrefix, data.Id)
|
||||||
orderNoKey := fmt.Sprintf("%s%v", cacheOrderNoPrefix, data.OrderNo)
|
orderNoKey := fmt.Sprintf("%s%v", cacheOrderNoPrefix, data.OrderNo)
|
||||||
cacheKeys := []string{
|
tradeNoKey := fmt.Sprintf("%s%v", cacheOrderTradePrefix, data.TradeNo)
|
||||||
orderIdKey,
|
cacheKeys := []string{
|
||||||
orderNoKey,
|
orderIdKey,
|
||||||
}
|
orderNoKey,
|
||||||
return cacheKeys
|
tradeNoKey,
|
||||||
|
}
|
||||||
|
return cacheKeys
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *defaultOrderModel) Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error {
|
func (m *defaultOrderModel) Insert(ctx context.Context, data *Order, tx ...*gorm.DB) error {
|
||||||
@ -106,6 +110,20 @@ func (m *defaultOrderModel) FindOneByOrderNo(ctx context.Context, orderNo string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *defaultOrderModel) FindOneByTradeNo(ctx context.Context, tradeNo string) (*Order, error) {
|
||||||
|
OrderTradeKey := fmt.Sprintf("%s%v", cacheOrderTradePrefix, tradeNo)
|
||||||
|
var resp Order
|
||||||
|
err := m.QueryCtx(ctx, &resp, OrderTradeKey, func(conn *gorm.DB, v interface{}) error {
|
||||||
|
return conn.Model(&Order{}).Where("`trade_no` = ?", tradeNo).First(&resp).Error
|
||||||
|
})
|
||||||
|
switch {
|
||||||
|
case err == nil:
|
||||||
|
return &resp, nil
|
||||||
|
default:
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (m *defaultOrderModel) Update(ctx context.Context, data *Order, tx ...*gorm.DB) error {
|
func (m *defaultOrderModel) Update(ctx context.Context, data *Order, tx ...*gorm.DB) error {
|
||||||
old, err := m.FindOne(ctx, data.Id)
|
old, err := m.FindOne(ctx, data.Id)
|
||||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
|||||||
@ -72,9 +72,18 @@ type AppUserSubscbribeNode struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type AppleLoginCallbackRequest struct {
|
type AppleLoginCallbackRequest struct {
|
||||||
Code string `form:"code"`
|
Code string `form:"code"`
|
||||||
IDToken string `form:"id_token"`
|
IDToken string `form:"id_token"`
|
||||||
State string `form:"state"`
|
State string `form:"state"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IAPVerifyRequest struct {
|
||||||
|
OriginalTransactionId string `json:"original_transaction_id" validate:"required"`
|
||||||
|
SubscribeId int64 `json:"subscribe_id" validate:"required"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type IAPVerifyResponse struct {
|
||||||
|
OrderNo string `json:"order_no"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type Application struct {
|
type Application struct {
|
||||||
|
|||||||
87
pkg/appleiap/jwks.go
Normal file
87
pkg/appleiap/jwks.go
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
package appleiap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type jwk struct {
|
||||||
|
Kty string `json:"kty"`
|
||||||
|
Kid string `json:"kid"`
|
||||||
|
Crv string `json:"crv"`
|
||||||
|
X string `json:"x"`
|
||||||
|
Y string `json:"y"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type jwks struct {
|
||||||
|
Keys []jwk `json:"keys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cacheEntry struct {
|
||||||
|
keys map[string]*ecdsa.PublicKey
|
||||||
|
exp time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
cache = map[string]*cacheEntry{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func endpoint(env string) string {
|
||||||
|
if env == "sandbox" {
|
||||||
|
return "https://api.storekit-sandbox.itunes.apple.com/inApps/v1/keys"
|
||||||
|
}
|
||||||
|
return "https://api.storekit.itunes.apple.com/inApps/v1/keys"
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetch(env string) (map[string]*ecdsa.PublicKey, error) {
|
||||||
|
resp, err := http.Get(endpoint(env))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
var set jwks
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&set); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
m := make(map[string]*ecdsa.PublicKey)
|
||||||
|
for _, k := range set.Keys {
|
||||||
|
if k.Kty != "EC" || k.Crv != "P-256" || k.X == "" || k.Y == "" || k.Kid == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
xb, err := base64.RawURLEncoding.DecodeString(k.X)
|
||||||
|
if err != nil { continue }
|
||||||
|
yb, err := base64.RawURLEncoding.DecodeString(k.Y)
|
||||||
|
if err != nil { continue }
|
||||||
|
var x, y big.Int
|
||||||
|
x.SetBytes(xb)
|
||||||
|
y.SetBytes(yb)
|
||||||
|
m[k.Kid] = &ecdsa.PublicKey{Curve: elliptic.P256(), X: &x, Y: &y}
|
||||||
|
}
|
||||||
|
if len(m) == 0 {
|
||||||
|
return nil, errors.New("empty jwks")
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetKey(env, kid string) (*ecdsa.PublicKey, error) {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
c := cache[env]
|
||||||
|
if c == nil || time.Now().After(c.exp) {
|
||||||
|
keys, err := fetch(env)
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
cache[env] = &cacheEntry{ keys: keys, exp: time.Now().Add(10 * time.Minute) }
|
||||||
|
c = cache[env]
|
||||||
|
}
|
||||||
|
k := c.keys[kid]
|
||||||
|
if k == nil { return nil, errors.New("key not found") }
|
||||||
|
return k, nil
|
||||||
|
}
|
||||||
29
pkg/appleiap/jws.go
Normal file
29
pkg/appleiap/jws.go
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
package appleiap
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func verifyWithEnv(env, token string) (jwt.MapClaims, error) {
|
||||||
|
parsed, err := jwt.Parse(token, func(t *jwt.Token) (interface{}, error) {
|
||||||
|
h, ok := t.Header["kid"].(string)
|
||||||
|
if !ok { return nil, errors.New("kid missing") }
|
||||||
|
return GetKey(env, h)
|
||||||
|
})
|
||||||
|
if err != nil { return nil, err }
|
||||||
|
if !parsed.Valid { return nil, errors.New("invalid jws") }
|
||||||
|
c, ok := parsed.Claims.(jwt.MapClaims)
|
||||||
|
if !ok { return nil, errors.New("claims invalid") }
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func VerifyWithEnv(env, token string) (jwt.MapClaims, error) { return verifyWithEnv(env, token) }
|
||||||
|
|
||||||
|
func VerifyAutoEnv(token string) (jwt.MapClaims, string, error) {
|
||||||
|
c, err := verifyWithEnv("production", token)
|
||||||
|
if err == nil { return c, "production", nil }
|
||||||
|
c2, err2 := verifyWithEnv("sandbox", token)
|
||||||
|
if err2 == nil { return c2, "sandbox", nil }
|
||||||
|
return nil, "", err
|
||||||
|
}
|
||||||
@ -10,6 +10,8 @@ const (
|
|||||||
EPay
|
EPay
|
||||||
Balance
|
Balance
|
||||||
CryptoSaaS
|
CryptoSaaS
|
||||||
|
AppleIAP
|
||||||
|
ApplePay
|
||||||
UNSUPPORTED Platform = -1
|
UNSUPPORTED Platform = -1
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -19,6 +21,8 @@ var platformNames = map[string]Platform{
|
|||||||
"AlipayF2F": AlipayF2F,
|
"AlipayF2F": AlipayF2F,
|
||||||
"EPay": EPay,
|
"EPay": EPay,
|
||||||
"balance": Balance,
|
"balance": Balance,
|
||||||
|
"AppleIAP": AppleIAP,
|
||||||
|
"ApplePay": ApplePay,
|
||||||
"unsupported": UNSUPPORTED,
|
"unsupported": UNSUPPORTED,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -80,5 +84,24 @@ func GetSupportedPlatforms() []types.PlatformInfo {
|
|||||||
"secret_key": "Secret Key",
|
"secret_key": "Secret Key",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Platform: AppleIAP.String(),
|
||||||
|
PlatformUrl: "https://developer.apple.com/help/app-store-connect/",
|
||||||
|
PlatformFieldDescription: map[string]string{
|
||||||
|
"issuer_id": "App Store Connect Issuer ID",
|
||||||
|
"key_id": "App Store Connect Key ID",
|
||||||
|
"private_key": "Private Key (ES256)",
|
||||||
|
"environment": "Environment: Sandbox/Production",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Platform: ApplePay.String(),
|
||||||
|
PlatformUrl: "https://stripe.com",
|
||||||
|
PlatformFieldDescription: map[string]string{
|
||||||
|
"public_key": "Publishable key",
|
||||||
|
"secret_key": "Secret key",
|
||||||
|
"webhook_secret": "Webhook secret",
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user