diff --git a/initialize/migrate/database/02141_order_iap_token.up.sql b/initialize/migrate/database/02141_order_iap_token.up.sql new file mode 100644 index 0000000..f2236d0 --- /dev/null +++ b/initialize/migrate/database/02141_order_iap_token.up.sql @@ -0,0 +1,5 @@ +-- Add subscription_user_id column to order table +ALTER TABLE `order` ADD COLUMN `subscription_user_id` bigint NOT NULL DEFAULT 0 COMMENT 'Target user ID for subscription (0=same as UserId)' AFTER `user_id`; + +-- Add app_account_token column to order table for Apple IAP +ALTER TABLE `order` ADD COLUMN `app_account_token` varchar(36) DEFAULT NULL COMMENT 'Apple IAP App Account Token (UUID)' AFTER `subscribe_token`; diff --git a/internal/model/iap/apple/default.go b/internal/model/iap/apple/default.go index 3311c37..9bca128 100644 --- a/internal/model/iap/apple/default.go +++ b/internal/model/iap/apple/default.go @@ -15,6 +15,7 @@ type Model interface { Insert(ctx context.Context, data *Transaction, tx ...*gorm.DB) error FindByOriginalId(ctx context.Context, originalId string) (*Transaction, error) FindByUserAndProduct(ctx context.Context, userId int64, productId string) (*Transaction, error) + FindLatestByUserId(ctx context.Context, userId int64) (*Transaction, error) } type defaultModel struct { @@ -66,3 +67,13 @@ func (m *customModel) FindByUserAndProduct(ctx context.Context, userId int64, pr return &data, err } +// FindLatestByUserId 查找指定用户最近的一条交易记录 +// 用于获取已知的 originalTransactionId,以便查询 Apple 交易历史 +func (m *customModel) FindLatestByUserId(ctx context.Context, userId int64) (*Transaction, error) { + var data Transaction + err := m.QueryNoCacheCtx(ctx, &data, func(conn *gorm.DB, v interface{}) error { + return conn.Model(&Transaction{}).Where("user_id = ?", userId).Order("purchase_at DESC").First(&data).Error + }) + return &data, err +} + diff --git a/pkg/iap/apple/serverapi.go b/pkg/iap/apple/serverapi.go index c34badc..f39a639 100644 --- a/pkg/iap/apple/serverapi.go +++ b/pkg/iap/apple/serverapi.go @@ -129,3 +129,78 @@ func GetTransactionInfo(cfg ServerAPIConfig, transactionId string) (string, erro } return "", fmt.Errorf("apple api error, primary[%d:%s], secondary[%d:%s]", code, body, code2, body2) } + +// GetTransactionHistory 获取指定交易 ID 的完整交易历史(该用户的所有交易) +// Apple API: GET /inApps/v2/history/{transactionId} +// 返回所有交易的 JWS 列表 +func GetTransactionHistory(cfg ServerAPIConfig, transactionId string) ([]string, error) { + token, err := buildAPIToken(cfg) + if err != nil { + return nil, err + } + + fetchPage := func(host, revision string) ([]string, string, bool, int, error) { + url := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host, transactionId) + if revision != "" { + url += "&revision=" + revision + } + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, "", false, 0, err + } + defer resp.Body.Close() + buf := new(bytes.Buffer) + _, _ = buf.ReadFrom(resp.Body) + if resp.StatusCode != 200 { + return nil, "", false, resp.StatusCode, fmt.Errorf("apple api error: %d %s", resp.StatusCode, buf.String()) + } + var result struct { + SignedTransactions []string `json:"signedTransactions"` + Revision string `json:"revision"` + HasMore bool `json:"hasMore"` + } + if err := json.Unmarshal(buf.Bytes(), &result); err != nil { + return nil, "", false, resp.StatusCode, err + } + return result.SignedTransactions, result.Revision, result.HasMore, resp.StatusCode, nil + } + + primary := "https://api.storekit.itunes.apple.com" + secondary := "https://api.storekit-sandbox.itunes.apple.com" + if cfg.Sandbox { + primary, secondary = secondary, primary + } + + // 尝试 primary 环境 + var allTx []string + revision := "" + for { + txs, rev, hasMore, _, err := fetchPage(primary, revision) + if err != nil { + break // primary 失败,尝试 secondary + } + allTx = append(allTx, txs...) + if !hasMore || rev == "" { + return allTx, nil + } + revision = rev + } + + // Fallback: secondary 环境 + allTx = nil + revision = "" + for { + txs, rev, hasMore, code, err := fetchPage(secondary, revision) + if err != nil { + return nil, fmt.Errorf("both environments failed, secondary[%d]: %v", code, err) + } + allTx = append(allTx, txs...) + if !hasMore || rev == "" { + return allTx, nil + } + revision = rev + } +} + diff --git a/queue/logic/iap/reconcileLogic.go b/queue/logic/iap/reconcileLogic.go index de36bd5..324547f 100644 --- a/queue/logic/iap/reconcileLogic.go +++ b/queue/logic/iap/reconcileLogic.go @@ -7,6 +7,7 @@ import ( "time" "github.com/hibiken/asynq" + "github.com/perfect-panel/server/internal/model/order" "github.com/perfect-panel/server/internal/model/payment" "github.com/perfect-panel/server/internal/svc" iapapple "github.com/perfect-panel/server/pkg/iap/apple" @@ -52,44 +53,112 @@ func (l *ReconcileLogic) reconcile(ctx context.Context, minAge, maxAge time.Dura logger.Infof("[IAPReconcile] found %d pending IAP orders (requireTradeNo=%v)", len(orders), requireTradeNo) for _, ord := range orders { - if ord.TradeNo == "" { - continue - } - // 用 originalTransactionId(即 trade_no)向 Apple Server API 查询交易详情 - jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo) - if e != nil { - logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v", - ord.OrderNo, ord.TradeNo, e) - time.Sleep(100 * time.Millisecond) - continue - } - // 解析 JWS(不校验证书链,只读取 payload) - txPayload, e := iapapple.ParseTransactionJWS(jws) - if e != nil { - logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e) - continue - } - if txPayload.RevocationDate != nil { - // 苹果已撤销交易 → 关闭订单 - logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo) - if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil { - logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr) - } - continue - } - // 正常已付款交易 → enqueue 激活(activateOrderLogic 内部有幂等保护) - payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo}) - task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload) - if _, e = l.svc.Queue.EnqueueContext(ctx, task); e != nil { - logger.Errorf("[IAPReconcile] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e) - } else { - logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo) + if ord.TradeNo != "" { + // 有 trade_no:直接用 transactionId 查询 Apple Server API + l.reconcileByTradeNo(ctx, apiCfg, ord) + } else if ord.AppAccountToken != "" { + // trade_no 为空但有 app_account_token:兜底查询 + l.reconcileByAppAccountToken(ctx, apiCfg, ord) } time.Sleep(100 * time.Millisecond) // 避免 Apple API 限频 } return nil } +// reconcileByTradeNo 通过 trade_no (originalTransactionId) 直接查询 Apple 交易状态 +func (l *ReconcileLogic) reconcileByTradeNo(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) { + jws, e := iapapple.GetTransactionInfo(*apiCfg, ord.TradeNo) + if e != nil { + logger.Errorf("[IAPReconcile] GetTransactionInfo error: orderNo=%s tradeNo=%s err=%v", + ord.OrderNo, ord.TradeNo, e) + return + } + txPayload, e := iapapple.ParseTransactionJWS(jws) + if e != nil { + logger.Errorf("[IAPReconcile] ParseTransactionJWS error: orderNo=%s err=%v", ord.OrderNo, e) + return + } + if txPayload.RevocationDate != nil { + logger.Infof("[IAPReconcile] transaction revoked, closing order: %s", ord.OrderNo) + if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil { + logger.Errorf("[IAPReconcile] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr) + } + return + } + // 正常已付款交易 → enqueue 激活 + payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo}) + task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload) + if _, e = l.svc.Queue.EnqueueContext(ctx, task); e != nil { + logger.Errorf("[IAPReconcile] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e) + } else { + logger.Infof("[IAPReconcile] enqueued activate: %s", ord.OrderNo) + } +} + +// reconcileByAppAccountToken 兜底:trade_no 为空但有 app_account_token +// 通过用户历史交易 ID 查询 Apple 交易历史,匹配 appAccountToken 找到对应交易 +func (l *ReconcileLogic) reconcileByAppAccountToken(ctx context.Context, apiCfg *iapapple.ServerAPIConfig, ord *order.Order) { + // 1. 获取该用户最近的已知交易 ID + lastTx, err := l.svc.IAPAppleTransactionModel.FindLatestByUserId(ctx, ord.UserId) + if err != nil || lastTx == nil || lastTx.OriginalTransactionId == "" { + logger.Infof("[IAPReconcile-Fallback] no historical transaction found for user %d, orderNo=%s, skip", + ord.UserId, ord.OrderNo) + return + } + + logger.Infof("[IAPReconcile-Fallback] searching Apple history for orderNo=%s appAccountToken=%s using transactionId=%s", + ord.OrderNo, ord.AppAccountToken, lastTx.OriginalTransactionId) + + // 2. 查询 Apple 交易历史 + jwsList, err := iapapple.GetTransactionHistory(*apiCfg, lastTx.OriginalTransactionId) + if err != nil { + logger.Errorf("[IAPReconcile-Fallback] GetTransactionHistory error: orderNo=%s err=%v", ord.OrderNo, err) + return + } + + // 3. 在交易历史中查找匹配 appAccountToken 的交易 + for _, jws := range jwsList { + txPayload, e := iapapple.ParseTransactionJWS(jws) + if e != nil { + continue + } + if txPayload.AppAccountToken != ord.AppAccountToken { + continue + } + // 匹配成功! + logger.Infof("[IAPReconcile-Fallback] MATCHED! orderNo=%s appAccountToken=%s transactionId=%s", + ord.OrderNo, ord.AppAccountToken, txPayload.TransactionId) + + if txPayload.RevocationDate != nil { + logger.Infof("[IAPReconcile-Fallback] transaction revoked, closing order: %s", ord.OrderNo) + if closeErr := l.svc.OrderModel.UpdateOrderStatus(ctx, ord.OrderNo, 3); closeErr != nil { + logger.Errorf("[IAPReconcile-Fallback] close revoked order failed: orderNo=%s err=%v", ord.OrderNo, closeErr) + } + return + } + + // 更新 trade_no 为找到的 originalTransactionId + if updateErr := l.svc.DB.Model(&order.Order{}). + Where("order_no = ?", ord.OrderNo). + Update("trade_no", txPayload.OriginalTransactionId).Error; updateErr != nil { + logger.Errorf("[IAPReconcile-Fallback] update trade_no error: orderNo=%s err=%v", ord.OrderNo, updateErr) + } + + // enqueue 激活 + payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: ord.OrderNo}) + task := asynq.NewTask(queueTypes.ForthwithActivateOrder, payload) + if _, e := l.svc.Queue.EnqueueContext(ctx, task); e != nil { + logger.Errorf("[IAPReconcile-Fallback] enqueue activate error: orderNo=%s err=%v", ord.OrderNo, e) + } else { + logger.Infof("[IAPReconcile-Fallback] enqueued activate: %s", ord.OrderNo) + } + return + } + + logger.Infof("[IAPReconcile-Fallback] no matching transaction found for orderNo=%s appAccountToken=%s (checked %d transactions)", + ord.OrderNo, ord.AppAccountToken, len(jwsList)) +} + // loadAppleAPIConfig 从 payment 表读取第一个启用的 Apple IAP 支付方式配置 func (l *ReconcileLogic) loadAppleAPIConfig(ctx context.Context) (*iapapple.ServerAPIConfig, error) { pays, err := l.svc.PaymentModel.FindListByPlatform(ctx, pkgpayment.AppleIAP.String()) diff --git a/scripts/test_apple_lookup.go b/scripts/test_apple_lookup.go new file mode 100644 index 0000000..690bd31 --- /dev/null +++ b/scripts/test_apple_lookup.go @@ -0,0 +1,345 @@ +package main + +// Apple App Store Server API 测试脚本 +// 用法: go run scripts/test_apple_lookup.go +// 功能: 通过 Apple Server API 获取交易历史,并按 appAccountToken (UUID) 过滤匹配交易 + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +// ==================== 配置区域 ==================== +// 请填入你的 Apple App Store Connect API 凭证 +const ( + keyID = "" // App Store Connect Key ID + issuerID = "" // App Store Connect Issuer ID + bundleID = "com.hifastvpn.vip" // 你的 App Bundle ID + sandbox = true // true=沙盒环境, false=生产环境 +) + +// 私钥内容 (PEM 格式) +// 从 App Store Connect 下载的 .p8 文件内容 +var privateKeyPEM = `` + +// ==================== 主逻辑 ==================== + +func main() { + if len(os.Args) < 2 { + fmt.Println("用法:") + fmt.Println(" go run scripts/test_apple_lookup.go [appAccountToken]") + fmt.Println("") + fmt.Println("参数:") + fmt.Println(" originalTransactionId Apple 交易原始 ID") + fmt.Println(" appAccountToken 可选, 用于过滤的 UUID (服务端下单时生成)") + fmt.Println("") + fmt.Println("示例:") + fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893") + fmt.Println(" go run scripts/test_apple_lookup.go 2000001132940893 f0eb8c62-4be9-4be7-9266-58d1a0a4e7bf") + os.Exit(1) + } + + originalTransactionId := os.Args[1] + filterToken := "" + if len(os.Args) >= 3 { + filterToken = os.Args[2] + } + + if keyID == "" || issuerID == "" || privateKeyPEM == "" { + fmt.Println("❌ 请先在脚本中填入 Apple API 凭证 (keyID, issuerID, privateKeyPEM)") + os.Exit(1) + } + + fmt.Println("═══════════════════════════════════════════════") + fmt.Println(" Apple App Store Server API - 交易历史查询") + fmt.Println("═══════════════════════════════════════════════") + fmt.Printf(" 环境: %s\n", envName()) + fmt.Printf(" TransactionID: %s\n", originalTransactionId) + if filterToken != "" { + fmt.Printf(" 过滤 Token: %s\n", filterToken) + } + fmt.Println("═══════════════════════════════════════════════") + + // 1. 生成 JWT + token, err := buildJWT() + if err != nil { + fmt.Printf("❌ 生成 JWT 失败: %v\n", err) + os.Exit(1) + } + fmt.Println("✅ JWT 生成成功") + + // 2. 查询交易历史 + fmt.Printf("\n📡 正在查询交易历史...\n") + transactions, err := getTransactionHistory(token, originalTransactionId) + if err != nil { + fmt.Printf("❌ 查询失败: %v\n", err) + os.Exit(1) + } + + fmt.Printf("✅ 共获取 %d 条交易记录\n\n", len(transactions)) + + // 3. 解析并展示交易 + for i, jws := range transactions { + info, err := parseJWS(jws) + if err != nil { + fmt.Printf(" [%d] ❌ 解析失败: %v\n", i+1, err) + continue + } + + txToken := info["appAccountToken"] + matched := "" + if filterToken != "" && fmt.Sprintf("%v", txToken) == filterToken { + matched = " ✅ 匹配!" + } + + fmt.Printf(" [%d]%s\n", i+1, matched) + fmt.Printf(" TransactionID: %v\n", info["transactionId"]) + fmt.Printf(" OriginalTxID: %v\n", info["originalTransactionId"]) + fmt.Printf(" ProductID: %v\n", info["productId"]) + fmt.Printf(" AppAccountToken: %v\n", txToken) + fmt.Printf(" PurchaseDate: %v\n", formatTimestamp(info["purchaseDate"])) + fmt.Printf(" Type: %v\n", info["type"]) + fmt.Println() + } + + // 4. 如果指定了过滤 token,显示匹配结果 + if filterToken != "" { + found := false + for _, jws := range transactions { + info, err := parseJWS(jws) + if err != nil { + continue + } + if fmt.Sprintf("%v", info["appAccountToken"]) == filterToken { + found = true + fmt.Println("═══════════════════════════════════════════════") + fmt.Printf("🎯 找到匹配的交易!AppAccountToken: %s\n", filterToken) + fmt.Printf(" TransactionID: %v\n", info["transactionId"]) + fmt.Printf(" ProductID: %v\n", info["productId"]) + fmt.Println("═══════════════════════════════════════════════") + break + } + } + if !found { + fmt.Println("═══════════════════════════════════════════════") + fmt.Printf("⚠️ 未找到 AppAccountToken=%s 的交易\n", filterToken) + fmt.Println("═══════════════════════════════════════════════") + } + } +} + +// ==================== Apple API 调用 ==================== + +// getTransactionHistory 获取指定交易 ID 的完整交易历史 +// Apple API: GET /inApps/v2/history/{transactionId} +func getTransactionHistory(jwt, transactionId string) ([]string, error) { + var allTransactions []string + revision := "" + + for { + host := apiHost() + url := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host, transactionId) + if revision != "" { + url += "&revision=" + revision + } + + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+jwt) + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, fmt.Errorf("HTTP 请求失败: %v", err) + } + defer resp.Body.Close() + + body, _ := io.ReadAll(resp.Body) + + if resp.StatusCode != 200 { + // 尝试另一个环境 + host2 := apiHostSecondary() + url2 := fmt.Sprintf("%s/inApps/v2/history/%s?sort=DESCENDING", host2, transactionId) + if revision != "" { + url2 += "&revision=" + revision + } + req2, _ := http.NewRequest("GET", url2, nil) + req2.Header.Set("Authorization", "Bearer "+jwt) + resp2, err2 := http.DefaultClient.Do(req2) + if err2 != nil { + return nil, fmt.Errorf("两个环境都失败: primary[%d:%s]", resp.StatusCode, string(body)) + } + defer resp2.Body.Close() + body2, _ := io.ReadAll(resp2.Body) + if resp2.StatusCode != 200 { + return nil, fmt.Errorf("两个环境都失败: primary[%d:%s], secondary[%d:%s]", + resp.StatusCode, string(body), resp2.StatusCode, string(body2)) + } + body = body2 + } + + var result struct { + SignedTransactions []string `json:"signedTransactions"` + Revision string `json:"revision"` + HasMore bool `json:"hasMore"` + } + if err := json.Unmarshal(body, &result); err != nil { + return nil, fmt.Errorf("解析响应失败: %v, body: %s", err, string(body)) + } + + allTransactions = append(allTransactions, result.SignedTransactions...) + + if !result.HasMore || result.Revision == "" { + break + } + revision = result.Revision + } + + return allTransactions, nil +} + +// ==================== JWT 构建 ==================== + +// buildJWT 构建 Apple Server API 的 ES256 JWT Token +func buildJWT() (string, error) { + header := map[string]interface{}{ + "alg": "ES256", + "kid": keyID, + "typ": "JWT", + } + now := time.Now().Unix() + payload := map[string]interface{}{ + "iss": issuerID, + "iat": now, + "exp": now + 1800, + "aud": "appstoreconnect-v1", + } + if bundleID != "" { + payload["bid"] = bundleID + } + + 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)) + + key := fixPEM(privateKeyPEM) + block, _ := pem.Decode([]byte(key)) + if block == nil { + return "", fmt.Errorf("invalid private key PEM") + } + keyAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return "", fmt.Errorf("parse private key failed: %v", err) + } + priv, ok := keyAny.(*ecdsa.PrivateKey) + if !ok { + return "", fmt.Errorf("private key is not ECDSA") + } + + h := sha256.New() + h.Write([]byte(unsigned)) + digest := h.Sum(nil) + + r, s, err := ecdsa.Sign(rand.Reader, priv, digest) + if err != nil { + return "", err + } + + curveBits := priv.Curve.Params().BitSize + keyBytes := curveBits / 8 + if curveBits%8 > 0 { + keyBytes++ + } + rBytes := r.Bytes() + rPadded := make([]byte, keyBytes) + copy(rPadded[keyBytes-len(rBytes):], rBytes) + sBytes := s.Bytes() + sPadded := make([]byte, keyBytes) + copy(sPadded[keyBytes-len(sBytes):], sBytes) + + sig := append(rPadded, sPadded...) + return unsigned + "." + base64.RawURLEncoding.EncodeToString(sig), nil +} + +// ==================== 工具函数 ==================== + +// parseJWS 解析 Apple 返回的 JWS (只取 payload 部分) +func parseJWS(jws string) (map[string]interface{}, error) { + parts := strings.Split(jws, ".") + if len(parts) != 3 { + return nil, fmt.Errorf("invalid JWS format") + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // 尝试标准 base64 + payload, err = base64.RawStdEncoding.DecodeString(parts[1]) + if err != nil { + return nil, fmt.Errorf("decode payload failed: %v", err) + } + } + var result map[string]interface{} + if err := json.Unmarshal(payload, &result); err != nil { + return nil, fmt.Errorf("unmarshal payload failed: %v", err) + } + return result, nil +} + +// formatTimestamp 格式化 Apple 返回的毫秒时间戳 +func formatTimestamp(v interface{}) string { + if v == nil { + return "N/A" + } + switch t := v.(type) { + case float64: + ts := time.UnixMilli(int64(t)) + return ts.Format("2006-01-02 15:04:05") + default: + return fmt.Sprintf("%v", v) + } +} + +func fixPEM(key string) string { + if !strings.Contains(key, "\n") && strings.Contains(key, "BEGIN PRIVATE KEY") { + key = strings.ReplaceAll(key, " ", "\n") + key = strings.ReplaceAll(key, "-----BEGIN\nPRIVATE\nKEY-----", "-----BEGIN PRIVATE KEY-----") + key = strings.ReplaceAll(key, "-----END\nPRIVATE\nKEY-----", "-----END PRIVATE KEY-----") + } + return key +} + +func apiHost() string { + if sandbox { + return "https://api.storekit-sandbox.itunes.apple.com" + } + return "https://api.storekit.itunes.apple.com" +} + +func apiHostSecondary() string { + if sandbox { + return "https://api.storekit.itunes.apple.com" + } + return "https://api.storekit-sandbox.itunes.apple.com" +} + +func envName() string { + if sandbox { + return "🏖️ Sandbox" + } + return "🏭 Production" +} + +// 忽略未使用导入 +var _ = bytes.NewBuffer