diff --git a/scripts/convert_recovery_orders.go b/scripts/convert_recovery_orders.go deleted file mode 100644 index e2d0e23..0000000 --- a/scripts/convert_recovery_orders.go +++ /dev/null @@ -1,227 +0,0 @@ -package main - -import ( - "encoding/json" - "flag" - "fmt" - "os" - "sort" - "strconv" - "strings" - "time" -) - -type remoteOrder struct { - OrderNo string `json:"order_no"` - OutOrderNo string `json:"out_order_no"` - OrderMoney json.Number `json:"order_money"` - OrderStatus string `json:"order_status"` - PaymentTime *int64 `json:"payment_time"` - CreateTime int64 `json:"create_time"` -} - -type recoveryFile struct { - Orders []recoveryOrder `json:"orders"` -} - -type recoveryOrder struct { - OutOrderNo string `json:"out_order_no"` - OrderNo string `json:"order_no"` - OrderStatus string `json:"order_status"` - SubscribeId int64 `json:"subscribe_id"` - ExpireAt int64 `json:"expire_at"` - Quantity int64 `json:"quantity"` - PaidAt int64 `json:"paid_at,omitempty"` - Note string `json:"note,omitempty"` - PaymentOrderNo string `json:"payment_order_no,omitempty"` - OrderMoneyCents int64 `json:"order_money_cents,omitempty"` -} - -type adminOrderResponse struct { - Code uint32 `json:"code"` - Msg string `json:"msg"` - Data struct { - Total int64 `json:"total"` - List []adminOrder `json:"list"` - } `json:"data"` -} - -type adminOrder struct { - OrderNo string `json:"order_no"` -} - -func main() { - var ( - input string - output string - adminOrders string - subscribeID int64 - onlyMissing bool - ) - flag.StringVar(&input, "in", "aaa.json", "input payment platform orders JSON") - flag.StringVar(&output, "out", "internal/recovery/recovery_orders.json", "output recovery orders JSON") - flag.StringVar(&adminOrders, "admin-orders", "", "optional admin order list response JSON; when provided, matched out_order_no values are skipped") - flag.Int64Var(&subscribeID, "subscribe-id", 1, "subscribe id to use for recovered subscriptions") - flag.BoolVar(&onlyMissing, "only-missing", true, "only output payment success orders missing from admin order list when -admin-orders is set") - flag.Parse() - - orders, err := readOrders(input) - must(err) - existingOrders := map[string]struct{}{} - if adminOrders != "" { - existingOrders, err = readAdminOrderSet(adminOrders) - must(err) - } - - daysByAmount := map[int64]int64{ - 1953: 7, - 4193: 30, - 9093: 90, - 31500: 365, - } - - out := recoveryFile{Orders: make([]recoveryOrder, 0, len(orders))} - var skipped []string - var matchedExisting int - seen := make(map[string]struct{}) - for _, item := range orders { - if strings.ToLower(strings.TrimSpace(item.OrderStatus)) != "success" { - continue - } - localOrderNo := strings.TrimSpace(item.OutOrderNo) - if localOrderNo == "" { - skipped = append(skipped, fmt.Sprintf("%s missing out_order_no", item.OrderNo)) - continue - } - if _, ok := seen[localOrderNo]; ok { - skipped = append(skipped, fmt.Sprintf("%s duplicate out_order_no", localOrderNo)) - continue - } - if _, exists := existingOrders[localOrderNo]; exists && onlyMissing { - matchedExisting++ - seen[localOrderNo] = struct{}{} - continue - } - amount := decimalYuanToCents(item.OrderMoney) - days, ok := daysByAmount[amount] - if !ok { - skipped = append(skipped, fmt.Sprintf("%s unknown amount %s", localOrderNo, item.OrderMoney.String())) - continue - } - base := item.CreateTime - if item.PaymentTime != nil && *item.PaymentTime > 0 { - base = *item.PaymentTime - } - if base <= 0 { - skipped = append(skipped, fmt.Sprintf("%s missing payment/create time", localOrderNo)) - continue - } - seen[localOrderNo] = struct{}{} - out.Orders = append(out.Orders, recoveryOrder{ - OutOrderNo: localOrderNo, - OrderNo: strings.TrimSpace(item.OrderNo), - OrderStatus: "success", - SubscribeId: subscribeID, - ExpireAt: time.Unix(base, 0).Add(time.Duration(days) * 24 * time.Hour).UnixMilli(), - Quantity: days, - PaidAt: base, - Note: "data recovery", - PaymentOrderNo: strings.TrimSpace(item.OrderNo), - OrderMoneyCents: amount, - }) - } - - sort.Slice(out.Orders, func(i, j int) bool { - return out.Orders[i].OutOrderNo < out.Orders[j].OutOrderNo - }) - - must(writeJSON(output, out)) - fmt.Printf("converted recovery orders=%d matched_existing=%d skipped=%d output=%s\n", len(out.Orders), matchedExisting, len(skipped), output) - for _, msg := range skipped { - fmt.Printf("skip: %s\n", msg) - } -} - -func readAdminOrderSet(path string) (map[string]struct{}, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var resp adminOrderResponse - if err := json.Unmarshal(data, &resp); err == nil && resp.Data.List != nil { - if resp.Code != 0 && resp.Code != 200 { - return nil, fmt.Errorf("admin order response failed: code=%d msg=%s", resp.Code, resp.Msg) - } - return adminOrdersToSet(resp.Data.List), nil - } - - var list []adminOrder - if err := json.Unmarshal(data, &list); err != nil { - return nil, err - } - return adminOrdersToSet(list), nil -} - -func adminOrdersToSet(list []adminOrder) map[string]struct{} { - set := make(map[string]struct{}, len(list)) - for _, item := range list { - orderNo := strings.TrimSpace(item.OrderNo) - if orderNo != "" { - set[orderNo] = struct{}{} - } - } - return set -} - -func readOrders(path string) ([]remoteOrder, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - var orders []remoteOrder - if err := json.Unmarshal(data, &orders); err != nil { - return nil, err - } - return orders, nil -} - -func writeJSON(path string, data recoveryFile) error { - bytes, err := json.MarshalIndent(data, "", " ") - if err != nil { - return err - } - bytes = append(bytes, '\n') - return os.WriteFile(path, bytes, 0644) -} - -func decimalYuanToCents(n json.Number) int64 { - s := strings.TrimSpace(n.String()) - if s == "" { - return 0 - } - parts := strings.SplitN(s, ".", 2) - yuan, _ := strconv.ParseInt(parts[0], 10, 64) - cents := yuan * 100 - if len(parts) == 1 { - return cents - } - frac := parts[1] - if len(frac) > 2 { - frac = frac[:2] - } - for len(frac) < 2 { - frac += "0" - } - f, _ := strconv.ParseInt(frac, 10, 64) - if strings.HasPrefix(parts[0], "-") { - return cents - f - } - return cents + f -} - -func must(err error) { - if err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} diff --git a/scripts/reconcile_mihapay_orders.go b/scripts/reconcile_mihapay_orders.go deleted file mode 100644 index aba8fb3..0000000 --- a/scripts/reconcile_mihapay_orders.go +++ /dev/null @@ -1,708 +0,0 @@ -package main - -import ( - "bytes" - "context" - "crypto/md5" - "encoding/hex" - "encoding/json" - "flag" - "fmt" - "io" - "net/http" - "net/url" - "os" - "sort" - "strconv" - "strings" - "time" - - "github.com/hibiken/asynq" - "github.com/perfect-panel/server/internal/config" - "github.com/perfect-panel/server/internal/model/order" - "github.com/perfect-panel/server/internal/model/payment" - "github.com/perfect-panel/server/internal/model/subscribe" - "github.com/perfect-panel/server/internal/model/user" - "github.com/perfect-panel/server/pkg/conf" - "github.com/perfect-panel/server/pkg/orm" - "github.com/perfect-panel/server/pkg/tool" - queueTypes "github.com/perfect-panel/server/queue/types" - "github.com/redis/go-redis/v9" - "gorm.io/gorm" -) - -const ( - defaultBaseURL = "https://gfiuseui.lkfezg.cn" - - // Hard-code Mihapay V2 credentials here when this reconciliation script should - // not depend on payment.config. Leave them empty to read pid/key/url from DB. - hardcodedMerchantID = "80567" - hardcodedMerchantKey = "475951745395f05a3be2ba7ca8235ba3" - hardcodedBaseURL = defaultBaseURL -) - -type options struct { - configPath string - baseURL string - paymentID int64 - method string - start string - end string - page int - limit int - maxPages int - apply bool - amountDiff int64 - verbose bool - printNotFound bool -} - -type merchantConfig struct { - merchantID string - key string - baseURL string -} - -type orderListResponse struct { - Code int `json:"code"` - Msg string `json:"msg"` - Data struct { - List []remoteOrder `json:"list"` - Total int `json:"total"` - } `json:"data"` -} - -type remoteOrder struct { - OrderNo string `json:"order_no"` - OutOrderNo string `json:"out_order_no"` - OrderMoney json.Number `json:"order_money"` - UserMoney json.Number `json:"user_money"` - OrderStatus string `json:"order_status"` - NotifyStatus string `json:"notify_status"` - CreateTime int64 `json:"create_time"` - PaymentTime int64 `json:"payment_time"` - NotifyTime int64 `json:"notify_time"` - ClientIP string `json:"client_ip"` - Device string `json:"device"` - OrderRemark string `json:"order_remark"` -} - -type matchedOrder struct { - Remote remoteOrder - Local order.Order - RemoteCents int64 - Audit subscriptionAudit - Action string - Reason string - PaymentAudit string -} - -type subscriptionAudit struct { - Checked bool - Status string - UserSubID int64 - UserID int64 - StartTime time.Time - ExpireTime time.Time - ExpectedExpire time.Time - SubscribeUnit string - Quantity int64 - Reason string -} - -type reconcileStats struct { - RemotePages int - RemoteOrders int - RemotePaid int - RemoteMissingNo int - DuplicateRemote int - LocalNotFound int - LocalDateFiltered int - LocalMethodSkip int - Matched int - StatusCounts map[string]int - LocalNotFoundOrders []remoteOrder -} - -func main() { - ctx := context.Background() - opt := parseFlags() - - var cfg config.Config - conf.MustLoad(opt.configPath, &cfg) - - db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL}) - must(err, "connect mysql") - sqlDB, err := db.DB() - must(err, "get sql db") - defer sqlDB.Close() - - var redisClient *redis.Client - var queueClient *asynq.Client - if opt.apply { - redisClient = redis.NewClient(&redis.Options{ - Addr: cfg.Redis.Host, - Password: cfg.Redis.Pass, - DB: cfg.Redis.DB, - }) - defer redisClient.Close() - - queueClient = asynq.NewClient(asynq.RedisClientOpt{ - Addr: cfg.Redis.Host, - Password: cfg.Redis.Pass, - DB: 5, - }) - defer queueClient.Close() - } - - merchant, paymentInfo, err := resolveMerchantConfig(ctx, db, opt) - must(err, "resolve mihapay config") - if opt.paymentID > 0 { - opt.method = paymentInfo.Platform - } - baseURL := opt.baseURL - if baseURL == defaultBaseURL && merchant.baseURL != "" { - baseURL = merchant.baseURL - } - - client := &http.Client{Timeout: 15 * time.Second} - matches, stats, err := reconcile(ctx, client, db, redisClient, queueClient, opt, baseURL, merchant, paymentInfo) - must(err, "reconcile orders") - - printSummary(matches, stats, opt) -} - -func parseFlags() options { - opt := options{} - flag.StringVar(&opt.configPath, "config", "etc/ppanel.yaml", "ppanel config path") - flag.StringVar(&opt.baseURL, "base-url", defaultBaseURL, "Mihapay base URL") - flag.Int64Var(&opt.paymentID, "payment-id", 0, "payment table id to use for merchant credentials") - flag.StringVar(&opt.method, "method", "epay", "local order method/platform to match when payment-id is not set") - flag.StringVar(&opt.start, "start", "", "local order created_at start, e.g. 2026-05-05 00:00:00") - flag.StringVar(&opt.end, "end", "", "local order created_at end, e.g. 2026-05-06 00:00:00") - flag.IntVar(&opt.page, "page", 1, "remote start page") - flag.IntVar(&opt.limit, "limit", 100, "remote page size") - flag.IntVar(&opt.maxPages, "max-pages", 50, "maximum remote pages to fetch") - flag.BoolVar(&opt.apply, "apply", false, "apply fixes; default is dry-run") - flag.Int64Var(&opt.amountDiff, "amount-diff-cents", 1, "allowed amount difference in cents") - flag.BoolVar(&opt.verbose, "v", false, "print diagnostic details") - flag.BoolVar(&opt.printNotFound, "print-not-found", false, "print remote paid orders that have no matching local order") - flag.Parse() - - if opt.start == "" { - now := time.Now() - opt.start = time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()).Format("2006-01-02 15:04:05") - } - if opt.end == "" { - start, err := time.ParseInLocation("2006-01-02 15:04:05", opt.start, time.Local) - if err == nil { - opt.end = start.AddDate(0, 0, 1).Format("2006-01-02 15:04:05") - } - } - opt.baseURL = strings.TrimRight(opt.baseURL, "/") - if opt.page <= 0 { - opt.page = 1 - } - if opt.limit <= 0 || opt.limit > 500 { - opt.limit = 100 - } - if opt.maxPages <= 0 { - opt.maxPages = 1 - } - return opt -} - -func resolveMerchantConfig(ctx context.Context, db *gorm.DB, opt options) (merchantConfig, *payment.Payment, error) { - if hardcodedMerchantID != "" && hardcodedMerchantKey != "" { - return merchantConfig{ - merchantID: hardcodedMerchantID, - key: hardcodedMerchantKey, - baseURL: strings.TrimRight(hardcodedBaseURL, "/"), - }, nil, nil - } - - var p payment.Payment - query := db.WithContext(ctx).Model(&payment.Payment{}) - if opt.paymentID > 0 { - query = query.Where("id = ?", opt.paymentID) - } else { - query = query.Where("LOWER(REPLACE(platform, '_', '')) = LOWER(REPLACE(?, '_', ''))", opt.method) - } - if err := query.First(&p).Error; err != nil { - return merchantConfig{}, nil, err - } - - var epayCfg payment.EPayConfig - if err := epayCfg.Unmarshal([]byte(p.Config)); err != nil { - return merchantConfig{}, nil, err - } - if epayCfg.Pid == "" || epayCfg.Key == "" { - return merchantConfig{}, nil, fmt.Errorf("payment id %d has empty pid/key", p.Id) - } - return merchantConfig{merchantID: epayCfg.Pid, key: epayCfg.Key, baseURL: strings.TrimRight(epayCfg.Url, "/")}, &p, nil -} - -func reconcile( - ctx context.Context, - client *http.Client, - db *gorm.DB, - redisClient *redis.Client, - queueClient *asynq.Client, - opt options, - baseURL string, - merchant merchantConfig, - paymentInfo *payment.Payment, -) ([]matchedOrder, reconcileStats, error) { - var matches []matchedOrder - var stats reconcileStats - stats.StatusCounts = make(map[string]int) - seen := make(map[string]struct{}) - page := opt.page - - for fetchedPages := 0; fetchedPages < opt.maxPages; fetchedPages++ { - resp, err := fetchRemoteOrders(ctx, client, baseURL, merchant, page, opt.limit) - if err != nil { - return matches, stats, err - } - if resp.Code != 1 { - return matches, stats, fmt.Errorf("remote orderlist failed: code=%d msg=%s", resp.Code, resp.Msg) - } - stats.RemotePages++ - stats.RemoteOrders += len(resp.Data.List) - if opt.verbose { - fmt.Printf("remote page=%d list=%d total=%d\n", page, len(resp.Data.List), resp.Data.Total) - } - if len(resp.Data.List) == 0 { - break - } - - for _, remote := range resp.Data.List { - status := strings.TrimSpace(remote.OrderStatus) - if status == "" { - status = "" - } - stats.StatusCounts[status]++ - if !isRemotePaidStatus(remote.OrderStatus) { - continue - } - stats.RemotePaid++ - if remote.OutOrderNo == "" { - stats.RemoteMissingNo++ - continue - } - if _, ok := seen[remote.OutOrderNo]; ok { - stats.DuplicateRemote++ - continue - } - seen[remote.OutOrderNo] = struct{}{} - - item, ok := matchRemoteOrder(ctx, db, opt, &stats, remote) - if !ok { - continue - } - stats.Matched++ - matches = append(matches, item) - if opt.apply && (item.Action == "补单" || item.Action == "重投队列") { - if err := applyOrderFix(ctx, db, redisClient, queueClient, paymentInfo, item); err != nil { - return matches, stats, err - } - } - } - - page++ - if resp.Data.Total > 0 && page*opt.limit >= resp.Data.Total+opt.limit { - break - } - } - return matches, stats, nil -} - -func isRemotePaidStatus(status string) bool { - switch strings.ToLower(strings.TrimSpace(status)) { - case "paid", "success", "succeeded", "trade_success", "completed", "complete", "1": - return true - default: - return false - } -} - -func fetchRemoteOrders(ctx context.Context, client *http.Client, baseURL string, merchant merchantConfig, page, limit int) (*orderListResponse, error) { - params := map[string]string{ - "merchantId": merchant.merchantID, - "page": strconv.Itoa(page), - "limit": strconv.Itoa(limit), - "order": "create_time desc", - } - params["sign"] = sign(params, merchant.key) - - form := url.Values{} - for k, v := range params { - form.Set(k, v) - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/api/v2/orderlist", strings.NewReader(form.Encode())) - if err != nil { - return nil, err - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - - resp, err := client.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - body, err := io.ReadAll(resp.Body) - if err != nil { - return nil, err - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("remote status %d: %s", resp.StatusCode, string(body)) - } - - decoder := json.NewDecoder(bytes.NewReader(body)) - decoder.UseNumber() - var out orderListResponse - if err := decoder.Decode(&out); err != nil { - return nil, fmt.Errorf("decode remote response: %w body=%s", err, string(body)) - } - return &out, nil -} - -func matchRemoteOrder(ctx context.Context, db *gorm.DB, opt options, stats *reconcileStats, remote remoteOrder) (matchedOrder, bool) { - var local order.Order - if err := db.WithContext(ctx).Model(&order.Order{}).Where("order_no = ?", remote.OutOrderNo).First(&local).Error; err != nil { - stats.LocalNotFound++ - stats.LocalNotFoundOrders = append(stats.LocalNotFoundOrders, remote) - if opt.verbose { - fmt.Printf("skip local_not_found out_order_no=%s remote_no=%s status=%s money=%s\n", remote.OutOrderNo, remote.OrderNo, remote.OrderStatus, remote.OrderMoney) - } - return matchedOrder{}, false - } - if opt.method != "" && local.Method != opt.method { - stats.LocalMethodSkip++ - if opt.verbose { - fmt.Printf("skip method out_order_no=%s local_method=%s want=%s\n", remote.OutOrderNo, local.Method, opt.method) - } - return matchedOrder{}, false - } - if opt.start != "" && local.CreatedAt.Format("2006-01-02 15:04:05") < opt.start { - stats.LocalDateFiltered++ - return matchedOrder{}, false - } - if opt.end != "" && local.CreatedAt.Format("2006-01-02 15:04:05") >= opt.end { - stats.LocalDateFiltered++ - return matchedOrder{}, false - } - - remoteCents := decimalYuanToCents(remote.OrderMoney) - item := matchedOrder{ - Remote: remote, - Local: local, - RemoteCents: remoteCents, - PaymentAudit: "远端已支付", - } - item.Audit = auditSubscription(ctx, db, local) - - switch { - case local.Status == 5: - item.Action = "跳过" - item.Reason = "本地已完成" - case remoteCents > 0 && abs64(local.Amount-remoteCents) > opt.amountDiff: - item.Action = "跳过" - item.Reason = fmt.Sprintf("金额不匹配 本地=%d 远端=%d", local.Amount, remoteCents) - case local.Status == 2: - item.Action = "重投队列" - item.Reason = "本地已支付但未完成" - case local.Status != 1 && local.Status != 3: - item.Action = "跳过" - item.Reason = fmt.Sprintf("本地状态 %d 不适合自动补单", local.Status) - default: - item.Action = "补单" - item.Reason = "远端已支付,本地未支付/已关闭" - } - return item, true -} - -func auditSubscription(ctx context.Context, db *gorm.DB, local order.Order) subscriptionAudit { - audit := subscriptionAudit{ - Checked: true, - Quantity: local.Quantity, - } - if local.Type != 1 && local.Type != 2 { - audit.Status = "跳过" - audit.Reason = fmt.Sprintf("订单类型 %d 不核对订阅时间", local.Type) - return audit - } - - var plan subscribe.Subscribe - if err := db.WithContext(ctx).Model(&subscribe.Subscribe{}).Where("id = ?", local.SubscribeId).First(&plan).Error; err != nil { - audit.Status = "异常" - audit.Reason = "套餐不存在: " + err.Error() - return audit - } - audit.SubscribeUnit = plan.UnitTime - - var userSub user.Subscribe - err := db.WithContext(ctx).Model(&user.Subscribe{}).Where("order_id = ?", local.Id).Order("id DESC").First(&userSub).Error - if err == nil { - audit.UserSubID = userSub.Id - audit.UserID = userSub.UserId - audit.StartTime = userSub.StartTime - audit.ExpireTime = userSub.ExpireTime - audit.ExpectedExpire = userSub.ExpireTime - audit.Status = "已核对" - audit.Reason = "订阅已绑定当前订单" - return audit - } - - if local.Type == 2 && local.SubscribeToken != "" { - err = db.WithContext(ctx).Model(&user.Subscribe{}).Where("token = ?", local.SubscribeToken).First(&userSub).Error - if err == nil { - base := subscriptionRenewalBaseTime(time.Now(), userSub.ExpireTime, userSub.FinishedAt) - audit.UserSubID = userSub.Id - audit.UserID = userSub.UserId - audit.StartTime = userSub.StartTime - audit.ExpireTime = userSub.ExpireTime - audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, base) - if local.Status == 5 { - audit.Status = "异常" - audit.Reason = "订单已完成但订阅 order_id 未指向该订单" - } else { - audit.Status = "待激活" - audit.Reason = "续费订单未完成,预计激活后续期" - } - return audit - } - } - - if local.Status == 5 { - audit.Status = "异常" - audit.Reason = "订单已完成但未找到对应订阅" - return audit - } - - audit.Status = "待激活" - audit.ExpectedExpire = tool.AddTime(plan.UnitTime, local.Quantity, time.Now()) - audit.Reason = "订单未完成,订阅时间需激活队列生成" - return audit -} - -func subscriptionRenewalBaseTime(now time.Time, expire time.Time, finishedAt *time.Time) time.Time { - base := expire - if finishedAt != nil && finishedAt.After(base) { - base = *finishedAt - } - if base.Before(now) { - base = now - } - return base -} - -func applyOrderFix( - ctx context.Context, - db *gorm.DB, - redisClient *redis.Client, - queueClient *asynq.Client, - paymentInfo *payment.Payment, - item matchedOrder, -) error { - return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - updates := map[string]interface{}{ - "status": 2, - "trade_no": item.Remote.OrderNo, - "updated_at": time.Now(), - } - if paymentInfo != nil { - updates["payment_id"] = paymentInfo.Id - updates["method"] = paymentInfo.Platform - } - result := tx.Model(&order.Order{}). - Where("order_no = ? AND status IN ?", item.Local.OrderNo, []uint8{1, 2, 3}). - Updates(updates) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return fmt.Errorf("order %s was not updated; status changed concurrently", item.Local.OrderNo) - } - clearOrderCache(ctx, redisClient, item.Local) - return enqueueActivation(ctx, queueClient, item.Local.OrderNo) - }) -} - -func enqueueActivation(ctx context.Context, queueClient *asynq.Client, orderNo string) error { - payload, err := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: orderNo}) - if err != nil { - return err - } - _, err = queueClient.EnqueueContext(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload, asynq.MaxRetry(5))) - return err -} - -func clearOrderCache(ctx context.Context, redisClient *redis.Client, local order.Order) { - keys := []string{ - fmt.Sprintf("cache:order:id:%d", local.Id), - fmt.Sprintf("cache:order:no:%s", local.OrderNo), - } - _ = redisClient.Del(ctx, keys...).Err() -} - -func sign(params map[string]string, key string) string { - keys := make([]string, 0, len(params)) - for k, v := range params { - if k != "sign" && k != "sign_type" && v != "" { - keys = append(keys, k) - } - } - sort.Strings(keys) - - parts := make([]string, 0, len(keys)) - for _, k := range keys { - parts = append(parts, k+"="+params[k]) - } - sum := md5.Sum([]byte(strings.Join(parts, "&") + key)) - return hex.EncodeToString(sum[:]) -} - -func decimalYuanToCents(n json.Number) int64 { - s := strings.TrimSpace(n.String()) - if s == "" { - return 0 - } - parts := strings.SplitN(s, ".", 2) - yuan, _ := strconv.ParseInt(parts[0], 10, 64) - cents := yuan * 100 - if len(parts) == 1 { - return cents - } - frac := parts[1] - if len(frac) > 2 { - frac = frac[:2] - } - for len(frac) < 2 { - frac += "0" - } - f, _ := strconv.ParseInt(frac, 10, 64) - if strings.HasPrefix(parts[0], "-") { - return cents - f - } - return cents + f -} - -func printSummary(matches []matchedOrder, stats reconcileStats, opt options) { - var fix, requeue, skip int - for _, item := range matches { - switch item.Action { - case "补单": - fix++ - case "重投队列": - requeue++ - default: - skip++ - } - fmt.Printf("%s order_no=%s remote_no=%s local_status=%d amount=%d remote_amount=%d reason=%s\n", - item.Action, - item.Local.OrderNo, - item.Remote.OrderNo, - item.Local.Status, - item.Local.Amount, - item.RemoteCents, - item.Reason, - ) - if item.Audit.Checked { - fmt.Printf(" payment=%s subscription=%s sub_id=%d user_id=%d expire=%s expected=%s note=%s\n", - item.PaymentAudit, - item.Audit.Status, - item.Audit.UserSubID, - item.Audit.UserID, - formatTime(item.Audit.ExpireTime), - formatTime(item.Audit.ExpectedExpire), - item.Audit.Reason, - ) - } - } - mode := "DRY-RUN" - if opt.apply { - mode = "APPLIED" - } - fmt.Printf("\nremote pages=%d orders=%d paid=%d missing_out_order_no=%d duplicates=%d\n", - stats.RemotePages, stats.RemoteOrders, stats.RemotePaid, stats.RemoteMissingNo, stats.DuplicateRemote) - if len(stats.StatusCounts) > 0 { - keys := make([]string, 0, len(stats.StatusCounts)) - for key := range stats.StatusCounts { - keys = append(keys, key) - } - sort.Strings(keys) - fmt.Print("remote status_counts=") - for i, key := range keys { - if i > 0 { - fmt.Print(", ") - } - fmt.Printf("%s:%d", key, stats.StatusCounts[key]) - } - fmt.Println() - } - fmt.Printf("local not_found=%d method_skipped=%d date_filtered=%d matched=%d\n", - stats.LocalNotFound, stats.LocalMethodSkip, stats.LocalDateFiltered, stats.Matched) - if opt.printNotFound && len(stats.LocalNotFoundOrders) > 0 { - printNotFoundOrders(stats.LocalNotFoundOrders) - } - fmt.Printf("\n%s summary: matched=%d fixable=%d requeue=%d skipped=%d\n", mode, len(matches), fix, requeue, skip) -} - -func printNotFoundOrders(items []remoteOrder) { - fmt.Println("\nlocal_not_found orders:") - fmt.Println("out_order_no\tremote_no\tstatus\torder_money\tuser_money\tcreate_time\tpayment_time\tclient_ip\tdevice\tremark") - for _, item := range items { - fmt.Printf("%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n", - item.OutOrderNo, - item.OrderNo, - item.OrderStatus, - item.OrderMoney.String(), - item.UserMoney.String(), - formatUnixTime(item.CreateTime), - formatUnixTime(item.PaymentTime), - oneLine(item.ClientIP), - oneLine(item.Device), - oneLine(item.OrderRemark), - ) - } -} - -func formatTime(t time.Time) string { - if t.IsZero() { - return "-" - } - return t.Format("2006-01-02 15:04:05") -} - -func formatUnixTime(v int64) string { - if v <= 0 { - return "-" - } - if v > 1_000_000_000_000 { - return time.UnixMilli(v).Format("2006-01-02 15:04:05") - } - return time.Unix(v, 0).Format("2006-01-02 15:04:05") -} - -func oneLine(s string) string { - s = strings.ReplaceAll(s, "\t", " ") - s = strings.ReplaceAll(s, "\r", " ") - s = strings.ReplaceAll(s, "\n", " ") - return strings.TrimSpace(s) -} - -func abs64(v int64) int64 { - if v < 0 { - return -v - } - return v -} - -func must(err error, step string) { - if err == nil { - return - } - fmt.Fprintf(os.Stderr, "%s: %v\n", step, err) - os.Exit(1) -}