feat(apple): 添加通过transaction_id附加苹果交易功能
Build docker and publish / build (20.15.1) (push) Successful in 6m41s
Build docker and publish / build (20.15.1) (push) Successful in 6m41s
新增通过transaction_id附加苹果交易的功能,包括: 1. 添加AttachAppleTransactionByIdRequest类型和对应路由 2. 实现AppleIAPConfig配置模型 3. 添加ServerAPI获取交易信息的实现 4. 优化JWS解析逻辑,增加cleanB64函数处理空格 5. 完善苹果通知处理逻辑的日志和注释
This commit is contained in:
+19
-7
@@ -8,14 +8,25 @@ import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
func cleanB64(s string) string {
|
||||
trimmed := strings.TrimSpace(s)
|
||||
return strings.Map(func(r rune) rune {
|
||||
if unicode.IsSpace(r) {
|
||||
return -1
|
||||
}
|
||||
return r
|
||||
}, trimmed)
|
||||
}
|
||||
|
||||
func ParseTransactionJWS(jws string) (*TransactionPayload, error) {
|
||||
parts := strings.Split(jws, ".")
|
||||
parts := strings.Split(strings.TrimSpace(jws), ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidJWS
|
||||
}
|
||||
payloadB64 := parts[1]
|
||||
payloadB64 := cleanB64(parts[1])
|
||||
// add padding if required
|
||||
switch len(payloadB64) % 4 {
|
||||
case 2:
|
||||
@@ -63,11 +74,11 @@ type jwsHeader struct {
|
||||
}
|
||||
|
||||
func VerifyTransactionJWS(jws string) (*TransactionPayload, error) {
|
||||
parts := strings.Split(jws, ".")
|
||||
parts := strings.Split(strings.TrimSpace(jws), ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidJWS
|
||||
}
|
||||
hdrB64 := parts[0]
|
||||
hdrB64 := cleanB64(parts[0])
|
||||
switch len(hdrB64) % 4 {
|
||||
case 2:
|
||||
hdrB64 += "=="
|
||||
@@ -97,13 +108,14 @@ func VerifyTransactionJWS(jws string) (*TransactionPayload, error) {
|
||||
if !ok {
|
||||
return nil, ErrInvalidJWS
|
||||
}
|
||||
signingInput := parts[0] + "." + parts[1]
|
||||
sig, err := base64.RawURLEncoding.DecodeString(parts[2])
|
||||
signingInput := cleanB64(parts[0]) + "." + cleanB64(parts[1])
|
||||
sig := cleanB64(parts[2])
|
||||
sigBytes, err := base64.RawURLEncoding.DecodeString(sig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d := sha256.Sum256([]byte(signingInput))
|
||||
if !ecdsa.VerifyASN1(pub, d[:], sig) {
|
||||
if !ecdsa.VerifyASN1(pub, d[:], sigBytes) {
|
||||
return nil, ErrInvalidJWS
|
||||
}
|
||||
return ParseTransactionJWS(jws)
|
||||
|
||||
@@ -12,11 +12,11 @@ type NotificationEnvelope struct {
|
||||
}
|
||||
|
||||
func ParseNotificationSignedPayload(jws string) (*NotificationEnvelope, error) {
|
||||
parts := strings.Split(jws, ".")
|
||||
parts := strings.Split(strings.TrimSpace(jws), ".")
|
||||
if len(parts) != 3 {
|
||||
return nil, ErrInvalidJWS
|
||||
}
|
||||
payloadB64 := parts[1]
|
||||
payloadB64 := cleanB64(parts[1])
|
||||
switch len(payloadB64) % 4 {
|
||||
case 2:
|
||||
payloadB64 += "=="
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
package apple
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerAPIConfig struct {
|
||||
KeyID string
|
||||
IssuerID string
|
||||
PrivateKey string
|
||||
Sandbox bool
|
||||
}
|
||||
|
||||
func buildAPIToken(cfg ServerAPIConfig) (string, error) {
|
||||
header := map[string]string{
|
||||
"alg": "ES256",
|
||||
"kid": cfg.KeyID,
|
||||
"typ": "JWT",
|
||||
}
|
||||
now := time.Now().Unix()
|
||||
payload := map[string]interface{}{
|
||||
"iss": cfg.IssuerID,
|
||||
"iat": now,
|
||||
"exp": now + 1800,
|
||||
"aud": "appstoreconnect-v1",
|
||||
}
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
enc := func(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
unsigned := fmt.Sprintf("%s.%s", enc(hb), enc(pb))
|
||||
block, _ := pem.Decode([]byte(cfg.PrivateKey))
|
||||
if block == nil {
|
||||
return "", fmt.Errorf("invalid private key")
|
||||
}
|
||||
keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
priv, ok := keyAny.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("private key is not ECDSA")
|
||||
}
|
||||
hash := unsigned // ES256 signs SHA-256 of input; jwt libs do hashing, we implement manually
|
||||
digest := sha256Sum([]byte(hash))
|
||||
sig, err := ecdsa.SignASN1(rand.Reader, priv, digest)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil
|
||||
}
|
||||
|
||||
func sha256Sum(b []byte) []byte {
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
return h.Sum(nil)
|
||||
}
|
||||
|
||||
func GetTransactionInfo(cfg ServerAPIConfig, transactionId string) (string, error) {
|
||||
token, err := buildAPIToken(cfg)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
try := func(host string) (string, int, string, error) {
|
||||
url := fmt.Sprintf("%s/inApps/v1/transactions/%s", host, transactionId)
|
||||
req, _ := http.NewRequest("GET", url, nil)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
return "", 0, "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
buf := new(bytes.Buffer)
|
||||
_, _ = buf.ReadFrom(resp.Body)
|
||||
if resp.StatusCode != 200 {
|
||||
return "", resp.StatusCode, buf.String(), fmt.Errorf("apple api error: %d", resp.StatusCode)
|
||||
}
|
||||
var body struct {
|
||||
SignedTransactionInfo string `json:"signedTransactionInfo"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &body); err != nil {
|
||||
return "", resp.StatusCode, buf.String(), err
|
||||
}
|
||||
return body.SignedTransactionInfo, resp.StatusCode, buf.String(), nil
|
||||
}
|
||||
primary := "https://api.storekit.itunes.apple.com"
|
||||
secondary := "https://api.storekit-sandbox.itunes.apple.com"
|
||||
if cfg.Sandbox {
|
||||
primary, secondary = secondary, primary
|
||||
}
|
||||
jws, code, body, err := try(primary)
|
||||
if err == nil && jws != "" {
|
||||
return jws, nil
|
||||
}
|
||||
// Fallback to the other environment if primary failed (common when env mismatches)
|
||||
jws2, code2, body2, err2 := try(secondary)
|
||||
if err2 == nil && jws2 != "" {
|
||||
return jws2, nil
|
||||
}
|
||||
return "", fmt.Errorf("apple api error, primary[%d:%s], secondary[%d:%s]", code, body, code2, body2)
|
||||
}
|
||||
Reference in New Issue
Block a user