x
Build docker and publish / build (20.15.1) (push) Has been cancelled

This commit is contained in:
2026-05-06 02:34:43 -07:00
parent 595e4c62f9
commit cbb451d18c
10 changed files with 2439 additions and 145 deletions
+708
View File
@@ -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 = "<empty>"
}
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)
}