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) }