diff --git a/internal/model/order/model.go b/internal/model/order/model.go index d858b3b..89d1b5f 100644 --- a/internal/model/order/model.go +++ b/internal/model/order/model.go @@ -160,8 +160,8 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, firstDay, lastDay, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -177,8 +177,8 @@ func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). Select( "SUM(amount) as amount_total, " + - "SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) as new_order_amount, " + - "SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) as renewal_order_amount", + "SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " + + "SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) as renewal_order_amount", ). Scan(v).Error }) @@ -192,8 +192,8 @@ func (m *customOrderModel) QueryTotalOrders(ctx context.Context) (OrdersTotal, e return conn.Model(&Order{}). Select(` SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Scan(&result).Error @@ -214,8 +214,8 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextMonth, "balance"). @@ -232,8 +232,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti err := m.QueryNoCacheCtx(ctx, nil, func(conn *gorm.DB, _ interface{}) error { return conn.Model(&Order{}). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance"). @@ -249,8 +249,8 @@ func (m *customOrderModel) QueryTotalUserCounts(ctx context.Context) (int64, int return conn.Model(&Order{}). Where("status IN ? AND method != ?", []int64{2, 5}, "balance"). Select(` - COUNT(DISTINCT CASE WHEN type = 1 THEN user_id END) AS new_users, - COUNT(DISTINCT CASE WHEN type = 2 THEN user_id END) AS renewal_users + COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users, + COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users `). Scan(&counts).Error }) @@ -282,8 +282,8 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T Select(` DATE_FORMAT(created_at, '%Y-%m-%d') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, firstDay, nextDay, "balance"). @@ -326,8 +326,8 @@ func (m *customOrderModel) QueryMonthlyOrdersList(ctx context.Context, date time Select(` DATE_FORMAT(created_at, '%Y-%m') AS date, SUM(amount) AS amount_total, - SUM(CASE WHEN type = 1 THEN amount ELSE 0 END) AS new_order_amount, - SUM(CASE WHEN type = 2 THEN amount ELSE 0 END) AS renewal_order_amount + SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) AS new_order_amount, + SUM(CASE WHEN is_new = 0 THEN amount ELSE 0 END) AS renewal_order_amount `). Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?", []int64{2, 5}, start, end, "balance"). diff --git a/scripts/convert_recovery_orders/main.go b/scripts/convert_recovery_orders/main.go new file mode 100644 index 0000000..e2d0e23 --- /dev/null +++ b/scripts/convert_recovery_orders/main.go @@ -0,0 +1,227 @@ +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/main.go b/scripts/reconcile_mihapay_orders/main.go new file mode 100644 index 0000000..aba8fb3 --- /dev/null +++ b/scripts/reconcile_mihapay_orders/main.go @@ -0,0 +1,708 @@ +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) +}