From 32e3dc3c738a9c8cd4fb4c59ad3b2e3ec859a488 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Wed, 29 Apr 2026 21:52:28 -0700 Subject: [PATCH] fix(order): cover invite gifts and inactive renewals --- internal/logic/auth/deviceLoginLogic.go | 20 + queue/logic/order/activateOrderLogic.go | 27 +- scripts/test_invite_gift_days.go | 485 ++++++++++++++++++++++++ 3 files changed, 518 insertions(+), 14 deletions(-) create mode 100644 scripts/test_invite_gift_days.go diff --git a/internal/logic/auth/deviceLoginLogic.go b/internal/logic/auth/deviceLoginLogic.go index 01a66d4..44e7775 100644 --- a/internal/logic/auth/deviceLoginLogic.go +++ b/internal/logic/auth/deviceLoginLogic.go @@ -6,6 +6,7 @@ import ( "time" "github.com/perfect-panel/server/internal/config" + commonLogic "github.com/perfect-panel/server/internal/logic/common" "github.com/perfect-panel/server/internal/model/log" "github.com/perfect-panel/server/internal/model/user" "github.com/perfect-panel/server/internal/svc" @@ -319,6 +320,25 @@ func (l *DeviceLoginLogic) tryGrantTrialForDeviceLogin(userInfo *user.User, iden return } + entitlement, err := commonLogic.ResolveEntitlementUser(l.ctx, l.svcCtx.DB, userInfo.Id) + if err != nil { + l.Errorw("failed to resolve family entitlement before device trial grant", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("error", err.Error()), + ) + return + } + if entitlement != nil && entitlement.EffectiveUserID != userInfo.Id { + l.Debugw("skip device trial grant because device user is a family member", + logger.Field("user_id", userInfo.Id), + logger.Field("identifier", identifier), + logger.Field("effective_user_id", entitlement.EffectiveUserID), + logger.Field("entitlement_source", entitlement.Source), + ) + return + } + var count int64 if err := l.svcCtx.DB.WithContext(l.ctx). Model(&user.Subscribe{}). diff --git a/queue/logic/order/activateOrderLogic.go b/queue/logic/order/activateOrderLogic.go index e98b5b1..2c8362c 100644 --- a/queue/logic/order/activateOrderLogic.go +++ b/queue/logic/order/activateOrderLogic.go @@ -447,6 +447,13 @@ func orderMergeRemainingTimeStatus(status uint8) bool { } } +func subscriptionRenewalBaseTime(now time.Time, userSub *user.Subscribe) time.Time { + if userSub != nil && orderMergeRemainingTimeStatus(userSub.Status) && userSub.ExpireTime.After(now) { + return userSub.ExpireTime + } + return now +} + func pickSubscriptionIdentitySource(candidates []user.Subscribe) *user.Subscribe { if len(candidates) == 0 { return nil @@ -959,10 +966,7 @@ func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId in // 若购买套餐与赠送套餐不同,同步更新套餐 ID 和流量配额并重置已用量(套餐变更语义)。 func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) { now := time.Now() - baseTime := giftSub.ExpireTime - if baseTime.Before(now) { - baseTime = now - } + baseTime := subscriptionRenewalBaseTime(now, giftSub) newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime) giftSub.OrderId = orderInfo.Id @@ -1417,10 +1421,7 @@ func (l *ActivateOrderLogic) getUserSubscription(ctx context.Context, token stri // updateSubscriptionWithIAPExpire 用于 Apple IAP 续费:按累计加时语义更新到期时间。 func (l *ActivateOrderLogic) updateSubscriptionWithIAPExpire(ctx context.Context, userSub *user.Subscribe, sub *subscribe.Subscribe, orderInfo *order.Order, iapExpireAt int64) error { now := time.Now() - baseTime := userSub.ExpireTime - if baseTime.Before(now) { - baseTime = now - } + baseTime := subscriptionRenewalBaseTime(now, userSub) newExpire := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime) if iapExpireAt > 0 { appleExpire := time.Unix(iapExpireAt, 0) @@ -1455,11 +1456,9 @@ func (l *ActivateOrderLogic) updateSubscriptionWithIAPExpire(ctx context.Context // expiration time extension and traffic reset if configured func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, userSub *user.Subscribe, sub *subscribe.Subscribe, orderInfo *order.Order) error { now := time.Now() - if userSub.ExpireTime.Before(now) { - userSub.ExpireTime = now - } - today := time.Now().Day() - resetDay := userSub.ExpireTime.Day() + baseTime := subscriptionRenewalBaseTime(now, userSub) + today := now.Day() + resetDay := baseTime.Day() // 套餐变更:更新套餐ID和流量配额,并重置已用流量 if userSub.SubscribeId != orderInfo.SubscribeId { @@ -1486,7 +1485,7 @@ func (l *ActivateOrderLogic) updateSubscriptionForRenewal(ctx context.Context, u } userSub.OrderId = orderInfo.Id - userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, userSub.ExpireTime) + userSub.ExpireTime = tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime) userSub.Status = 1 // 续费时重置过期流量字段 userSub.ExpiredDownload = 0 diff --git a/scripts/test_invite_gift_days.go b/scripts/test_invite_gift_days.go new file mode 100644 index 0000000..50b0a5d --- /dev/null +++ b/scripts/test_invite_gift_days.go @@ -0,0 +1,485 @@ +package main + +import ( + "context" + "database/sql" + "encoding/json" + "flag" + "fmt" + "os" + "strings" + "time" + + _ "github.com/go-sql-driver/mysql" +) + +const inviteGiftMarker = "codex-test-invite-gift-days" + +type giftLog struct { + Type uint16 `json:"type"` + OrderNo string `json:"order_no"` + SubscribeId int64 `json:"subscribe_id"` + Amount int64 `json:"amount"` + Balance int64 `json:"balance"` + Remark string `json:"remark,omitempty"` + Timestamp int64 `json:"timestamp"` +} + +type commissionLog struct { + Type uint16 `json:"type"` + Amount int64 `json:"amount"` + OrderNo string `json:"order_no"` + Timestamp int64 `json:"timestamp"` +} + +type userSubscribe struct { + ID int64 + UserID int64 + ExpireTime time.Time +} + +func main() { + var ( + dsn = flag.String("dsn", "", "MySQL DSN, for example root:pass@tcp(host:3306)/ppanel?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai") + writeDB = flag.Bool("write-db", false, "create isolated rows, simulate invite gifts, and clean them up") + keep = flag.Bool("keep", false, "keep rows for manual inspection") + cleanupOnly = flag.Bool("cleanup-only", false, "delete leftover rows created by this script and exit") + giftDays = flag.Int("gift-days", 3, "days to add to both invite users") + commission = flag.Int64("commission-percent", 10, "commission percent for commission-path simulation") + ) + flag.Parse() + + if *dsn == "" { + exitf("-dsn is required") + } + + ctx := context.Background() + db, err := sql.Open("mysql", *dsn) + mustNoErr(err) + defer db.Close() + db.SetMaxIdleConns(1) + db.SetMaxOpenConns(1) + mustNoErr(db.PingContext(ctx)) + + if *cleanupOnly { + mustNoErr(cleanup(ctx, db)) + fmt.Println("cleanup done") + return + } + + if !*writeDB { + fmt.Println("dry run only. Add -write-db to create isolated invite rows in the TEST database.") + return + } + if *giftDays <= 0 { + exitf("-gift-days must be positive") + } + + mustNoErr(cleanup(ctx, db)) + if !*keep { + defer func() { + if err := cleanup(context.Background(), db); err != nil { + fmt.Fprintf(os.Stderr, "cleanup failed: %v\n", err) + } + }() + } + + planID := mustCreatePlan(ctx, db) + runSelfInviteScenario(ctx, db, planID, *giftDays) + runFamilyInviteScenario(ctx, db, planID, *giftDays) + runCommissionScenario(ctx, db, planID, *giftDays, *commission) + + if *keep { + fmt.Println("rows kept; cleanup with -cleanup-only. inviteGiftMarker:", inviteGiftMarker) + } +} + +func runSelfInviteScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int) { + refererID := mustCreateUser(ctx, db, "self-referer", 0) + refereeID := mustCreateUser(ctx, db, "self-referee", refererID) + + baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second) + refererSubID := mustCreateUserSubscribe(ctx, db, refererID, planID, baseExpire) + refereeSubID := mustCreateUserSubscribe(ctx, db, refereeID, planID, baseExpire) + + orderNo := fmt.Sprintf("%s-self-order-%d", inviteGiftMarker, time.Now().UnixNano()) + mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererID, refereeID, 0, giftDays)) + mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererID, refereeID, 0, giftDays)) + + assertExpire(ctx, db, "referer", refererSubID, baseExpire, giftDays) + assertExpire(ctx, db, "referee", refereeSubID, baseExpire, giftDays) + + logs := mustGiftLogCount(ctx, db, orderNo) + if logs != 2 { + exitf("gift log count mismatch after duplicate simulation: got=%d want=2", logs) + } + + fmt.Printf("PASS self invite: referer=%d referee=%d order=%s gift_days=%d logs=%d\n", refererID, refereeID, orderNo, giftDays, logs) +} + +func runFamilyInviteScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int) { + refererOwnerID := mustCreateUser(ctx, db, "family-referer-owner", 0) + refererMemberID := mustCreateUser(ctx, db, "family-referer-member", 0) + refereeOwnerID := mustCreateUser(ctx, db, "family-referee-owner", 0) + refereeMemberID := mustCreateUser(ctx, db, "family-referee-member", refererMemberID) + mustCreateFamily(ctx, db, refererOwnerID, refererMemberID) + mustCreateFamily(ctx, db, refereeOwnerID, refereeMemberID) + + baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second) + refererOwnerSubID := mustCreateUserSubscribe(ctx, db, refererOwnerID, planID, baseExpire) + refereeOwnerSubID := mustCreateUserSubscribe(ctx, db, refereeOwnerID, planID, baseExpire) + refererMemberSubID := mustCreateUserSubscribe(ctx, db, refererMemberID, planID, baseExpire) + refereeMemberSubID := mustCreateUserSubscribe(ctx, db, refereeMemberID, planID, baseExpire) + + orderNo := fmt.Sprintf("%s-family-order-%d", inviteGiftMarker, time.Now().UnixNano()) + mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererMemberID, refereeMemberID, refereeOwnerID, giftDays)) + mustNoErr(simulateInviteGiftBoth(ctx, db, orderNo, refererMemberID, refereeMemberID, refereeOwnerID, giftDays)) + + assertExpire(ctx, db, "referer owner", refererOwnerSubID, baseExpire, giftDays) + assertExpire(ctx, db, "referee owner", refereeOwnerSubID, baseExpire, giftDays) + assertExpire(ctx, db, "referer member", refererMemberSubID, baseExpire, 0) + assertExpire(ctx, db, "referee member", refereeMemberSubID, baseExpire, 0) + + logs := mustGiftLogCount(ctx, db, orderNo) + if logs != 2 { + exitf("family gift log count mismatch after duplicate simulation: got=%d want=2", logs) + } + fmt.Printf("PASS family invite: referer_member=%d->owner=%d referee_member=%d->owner=%d order=%s gift_days=%d logs=%d\n", + refererMemberID, refererOwnerID, refereeMemberID, refereeOwnerID, orderNo, giftDays, logs) +} + +func runCommissionScenario(ctx context.Context, db *sql.DB, planID int64, giftDays int, commissionPercent int64) { + if commissionPercent <= 0 { + fmt.Println("SKIP commission invite: commission-percent <= 0") + return + } + const amount int64 = 599 + + refererID := mustCreateUser(ctx, db, "commission-referer", 0) + refereeID := mustCreateUser(ctx, db, "commission-referee", refererID) + baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Second) + refererSubID := mustCreateUserSubscribe(ctx, db, refererID, planID, baseExpire) + refereeSubID := mustCreateUserSubscribe(ctx, db, refereeID, planID, baseExpire) + + orderNo := fmt.Sprintf("%s-commission-first-order-%d", inviteGiftMarker, time.Now().UnixNano()) + mustNoErr(simulateInviteCommission(ctx, db, orderNo, refererID, refereeID, 0, giftDays, amount, commissionPercent, true)) + mustNoErr(simulateInviteCommission(ctx, db, orderNo, refererID, refereeID, 0, giftDays, amount, commissionPercent, true)) + + wantCommission := amount * commissionPercent / 100 + assertExpire(ctx, db, "commission referer", refererSubID, baseExpire, 0) + assertExpire(ctx, db, "commission referee", refereeSubID, baseExpire, giftDays) + assertCommission(ctx, db, refererID, wantCommission) + assertLogCount(ctx, db, "commission first gift", 34, orderNo, 1) + assertLogCount(ctx, db, "commission first commission", 33, orderNo, 1) + + nonFirstRefererID := mustCreateUser(ctx, db, "commission-nonfirst-referer", 0) + nonFirstRefereeID := mustCreateUser(ctx, db, "commission-nonfirst-referee", nonFirstRefererID) + nonFirstRefererSubID := mustCreateUserSubscribe(ctx, db, nonFirstRefererID, planID, baseExpire) + nonFirstRefereeSubID := mustCreateUserSubscribe(ctx, db, nonFirstRefereeID, planID, baseExpire) + nonFirstOrderNo := fmt.Sprintf("%s-commission-nonfirst-order-%d", inviteGiftMarker, time.Now().UnixNano()) + mustNoErr(simulateInviteCommission(ctx, db, nonFirstOrderNo, nonFirstRefererID, nonFirstRefereeID, 0, giftDays, amount, commissionPercent, false)) + mustNoErr(simulateInviteCommission(ctx, db, nonFirstOrderNo, nonFirstRefererID, nonFirstRefereeID, 0, giftDays, amount, commissionPercent, false)) + + assertExpire(ctx, db, "commission non-first referer", nonFirstRefererSubID, baseExpire, 0) + assertExpire(ctx, db, "commission non-first referee", nonFirstRefereeSubID, baseExpire, 0) + assertCommission(ctx, db, nonFirstRefererID, wantCommission) + assertLogCount(ctx, db, "commission non-first gift", 34, nonFirstOrderNo, 0) + assertLogCount(ctx, db, "commission non-first commission", 33, nonFirstOrderNo, 1) + + fmt.Printf("PASS commission invite: percent=%d first_order_commission=%d non_first_commission=%d\n", + commissionPercent, wantCommission, wantCommission) +} + +func assertExpire(ctx context.Context, db *sql.DB, label string, subID int64, before time.Time, addedDays int) { + got := mustExpire(ctx, db, subID) + want := before.Add(time.Duration(addedDays) * 24 * time.Hour) + if !got.Equal(want) { + exitf("%s expire mismatch: got=%s want=%s", label, got, want) + } + fmt.Printf("PASS %s subscribe=%d expire %s -> %s\n", label, subID, before.Format(time.RFC3339), got.Format(time.RFC3339)) +} + +func simulateInviteGiftBoth(ctx context.Context, db *sql.DB, orderNo string, refererID, refereeID, forcedRefereeOwnerID int64, days int) error { + refereeTargetID, err := resolveGiftTargetUser(ctx, db, refereeID, forcedRefereeOwnerID) + if err != nil { + return fmt.Errorf("resolve referee gift target: %w", err) + } + refererTargetID, err := resolveGiftTargetUser(ctx, db, refererID, 0) + if err != nil { + return fmt.Errorf("resolve referer gift target: %w", err) + } + if err := grantGiftDays(ctx, db, refereeTargetID, orderNo, days); err != nil { + return fmt.Errorf("grant referee gift: %w", err) + } + if err := grantGiftDays(ctx, db, refererTargetID, orderNo, days); err != nil { + return fmt.Errorf("grant referer gift: %w", err) + } + return nil +} + +func simulateInviteCommission(ctx context.Context, db *sql.DB, orderNo string, refererID, refereeID, forcedRefereeOwnerID int64, days int, amount int64, commissionPercent int64, isFirstOrder bool) error { + if err := grantCommission(ctx, db, refererID, orderNo, amount, commissionPercent); err != nil { + return fmt.Errorf("grant commission: %w", err) + } + if isFirstOrder { + refereeTargetID, err := resolveGiftTargetUser(ctx, db, refereeID, forcedRefereeOwnerID) + if err != nil { + return fmt.Errorf("resolve referee gift target: %w", err) + } + if err := grantGiftDays(ctx, db, refereeTargetID, orderNo, days); err != nil { + return fmt.Errorf("grant commission-path referee gift: %w", err) + } + } + return nil +} + +func resolveGiftTargetUser(ctx context.Context, db *sql.DB, userID int64, forcedOwnerID int64) (int64, error) { + if forcedOwnerID > 0 { + return forcedOwnerID, nil + } + var ownerID int64 + err := db.QueryRowContext(ctx, ` +SELECT uf.owner_user_id +FROM user_family_member ufm +JOIN user_family uf ON uf.id = ufm.family_id AND uf.deleted_at IS NULL +WHERE ufm.user_id = ? + AND ufm.deleted_at IS NULL + AND ufm.status = 1 + AND ufm.role = 2 + AND uf.status = 1 +ORDER BY ufm.role +LIMIT 1`, userID).Scan(&ownerID) + if err == sql.ErrNoRows { + return userID, nil + } + if err != nil { + return 0, err + } + if ownerID > 0 && ownerID != userID { + return ownerID, nil + } + return userID, nil +} + +func grantCommission(ctx context.Context, db *sql.DB, refererID int64, orderNo string, amount int64, commissionPercent int64) error { + var existing int64 + err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM system_logs WHERE type = 33 AND object_id = ? AND content LIKE ?", + refererID, "%\""+orderNo+"\"%", + ).Scan(&existing) + if err != nil { + return err + } + if existing > 0 { + return nil + } + + commissionAmount := amount * commissionPercent / 100 + if _, err = db.ExecContext(ctx, + "UPDATE `user` SET commission = commission + ?, updated_at = ? WHERE id = ?", + commissionAmount, time.Now(), refererID, + ); err != nil { + return err + } + + content, err := json.Marshal(commissionLog{ + Type: 331, + Amount: commissionAmount, + OrderNo: orderNo, + Timestamp: time.Now().UnixMilli(), + }) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, + "INSERT INTO system_logs (`type`, object_id, content, created_at, `date`) VALUES (33, ?, ?, ?, ?)", + refererID, string(content), time.Now(), time.Now().Format("2006-01-02"), + ) + return err +} + +func grantGiftDays(ctx context.Context, db *sql.DB, userID int64, orderNo string, days int) error { + var existing int64 + err := db.QueryRowContext(ctx, + "SELECT COUNT(*) FROM system_logs WHERE type = 34 AND object_id = ? AND content LIKE ?", + userID, "%\""+orderNo+"\"%", + ).Scan(&existing) + if err != nil { + return err + } + if existing > 0 { + return nil + } + + sub, err := findActiveSubscribe(ctx, db, userID) + if err != nil { + return err + } + nextExpire := sub.ExpireTime + if !sub.ExpireTime.Equal(time.UnixMilli(0)) { + nextExpire = sub.ExpireTime.Add(time.Duration(days) * 24 * time.Hour) + if _, err = db.ExecContext(ctx, + "UPDATE user_subscribe SET expire_time = ?, updated_at = ? WHERE id = ?", + nextExpire, time.Now(), sub.ID, + ); err != nil { + return err + } + } + + content, err := json.Marshal(giftLog{ + Type: 341, + OrderNo: orderNo, + SubscribeId: sub.ID, + Amount: int64(days), + Balance: 0, + Remark: "邀请赠送", + Timestamp: time.Now().UnixMilli(), + }) + if err != nil { + return err + } + _, err = db.ExecContext(ctx, + "INSERT INTO system_logs (`type`, object_id, content, created_at, `date`) VALUES (34, ?, ?, ?, ?)", + userID, string(content), time.Now(), time.Now().Format("2006-01-02"), + ) + return err +} + +func findActiveSubscribe(ctx context.Context, db *sql.DB, userID int64) (*userSubscribe, error) { + var row userSubscribe + err := db.QueryRowContext(ctx, ` +SELECT id, user_id, expire_time +FROM user_subscribe +WHERE user_id = ? + AND status IN (0, 1) + AND (expire_time > ? OR expire_time = '1970-01-01 08:00:00') +ORDER BY expire_time DESC, id DESC +LIMIT 1`, userID, time.Now()).Scan(&row.ID, &row.UserID, &row.ExpireTime) + if err != nil { + return nil, err + } + return &row, nil +} + +func mustCreatePlan(ctx context.Context, db *sql.DB) int64 { + var sort int64 + mustNoErr(db.QueryRowContext(ctx, "SELECT COALESCE(MAX(sort), 0) + 1 FROM subscribe").Scan(&sort)) + res, err := db.ExecContext(ctx, ` +INSERT INTO subscribe +(name, language, description, unit_price, unit_time, discount, replacement, inventory, traffic, speed_limit, device_limit, quota, new_user_only, nodes, node_tags, node_group_ids, node_group_id, traffic_limit, `+"`show`"+`, sell, sort, deduction_ratio, allow_deduction, reset_cycle, renewal_reset, show_original_price, created_at, updated_at) +VALUES (?, 'en', '', 599, 'Month', '', 0, -1, 1073741824, 0, 0, 0, false, '', '', '[]', 0, '', false, false, ?, 0, true, 0, false, true, ?, ?)`, + inviteGiftMarker+"-plan", sort, time.Now(), time.Now()) + mustNoErr(err) + id, err := res.LastInsertId() + mustNoErr(err) + return id +} + +func mustCreateUser(ctx context.Context, db *sql.DB, role string, refererID int64) int64 { + res, err := db.ExecContext(ctx, ` +INSERT INTO `+"`user`"+` +(password, algo, avatar, balance, refer_code, referer_id, commission, referral_percentage, only_first_purchase, gift_amount, enable, is_admin, enable_balance_notify, enable_login_notify, enable_subscribe_notify, enable_trade_notify, rules, member_status, remark, created_at, updated_at, salt) +VALUES (?, 'default', '', 0, '', ?, 0, 0, true, 0, true, false, true, true, true, true, '', '', ?, ?, ?, 'default')`, + inviteGiftMarker, refererID, inviteGiftMarker+"-"+role, time.Now(), time.Now()) + mustNoErr(err) + id, err := res.LastInsertId() + mustNoErr(err) + _, err = db.ExecContext(ctx, "UPDATE `user` SET refer_code = ?, updated_at = ? WHERE id = ?", fmt.Sprintf("codex%d", id), time.Now(), id) + mustNoErr(err) + return id +} + +func mustCreateFamily(ctx context.Context, db *sql.DB, ownerID, memberID int64) int64 { + res, err := db.ExecContext(ctx, ` +INSERT INTO user_family +(owner_user_id, max_members, status, created_at, updated_at) +VALUES (?, 3, 1, ?, ?)`, ownerID, time.Now(), time.Now()) + mustNoErr(err) + familyID, err := res.LastInsertId() + mustNoErr(err) + + now := time.Now() + _, err = db.ExecContext(ctx, ` +INSERT INTO user_family_member +(family_id, user_id, role, status, join_source, joined_at, created_at, updated_at) +VALUES +(?, ?, 1, 1, ?, ?, ?, ?), +(?, ?, 2, 1, ?, ?, ?, ?)`, + familyID, ownerID, inviteGiftMarker, now, now, now, + familyID, memberID, inviteGiftMarker, now, now, now) + mustNoErr(err) + return familyID +} + +func mustCreateUserSubscribe(ctx context.Context, db *sql.DB, userID, planID int64, expire time.Time) int64 { + token := fmt.Sprintf("%s-token-%d-%d", inviteGiftMarker, userID, time.Now().UnixNano()) + uuid := fmt.Sprintf("%08d-0000-4000-8000-%012d", userID, time.Now().UnixNano()%1_000_000_000_000) + res, err := db.ExecContext(ctx, ` +INSERT INTO user_subscribe +(user_id, order_id, subscribe_id, node_group_id, group_locked, traffic, download, upload, expired_download, expired_upload, token, uuid, status, note, created_at, updated_at, start_time, expire_time) +VALUES (?, 0, ?, 0, false, 1073741824, 0, 0, 0, 0, ?, ?, 1, ?, ?, ?, ?, ?)`, + userID, planID, token, uuid, inviteGiftMarker, time.Now(), time.Now(), time.Now().Add(-time.Hour), expire) + mustNoErr(err) + id, err := res.LastInsertId() + mustNoErr(err) + return id +} + +func mustExpire(ctx context.Context, db *sql.DB, subID int64) time.Time { + var expire time.Time + mustNoErr(db.QueryRowContext(ctx, "SELECT expire_time FROM user_subscribe WHERE id = ?", subID).Scan(&expire)) + return expire +} + +func mustGiftLogCount(ctx context.Context, db *sql.DB, orderNo string) int64 { + var count int64 + mustNoErr(db.QueryRowContext(ctx, "SELECT COUNT(*) FROM system_logs WHERE type = 34 AND content LIKE ?", "%"+orderNo+"%").Scan(&count)) + return count +} + +func assertCommission(ctx context.Context, db *sql.DB, userID int64, want int64) { + var got int64 + mustNoErr(db.QueryRowContext(ctx, "SELECT commission FROM `user` WHERE id = ?", userID).Scan(&got)) + if got != want { + exitf("commission mismatch: user=%d got=%d want=%d", userID, got, want) + } + fmt.Printf("PASS commission user=%d amount=%d\n", userID, got) +} + +func assertLogCount(ctx context.Context, db *sql.DB, label string, logType uint8, orderNo string, want int64) { + var got int64 + mustNoErr(db.QueryRowContext(ctx, "SELECT COUNT(*) FROM system_logs WHERE type = ? AND content LIKE ?", logType, "%"+orderNo+"%").Scan(&got)) + if got != want { + exitf("%s log count mismatch: got=%d want=%d", label, got, want) + } + fmt.Printf("PASS %s logs=%d\n", label, got) +} + +func cleanup(ctx context.Context, db *sql.DB) error { + stmts := []string{ + "DELETE FROM user_family_member WHERE join_source = '" + inviteGiftMarker + "'", + "DELETE FROM user_family WHERE owner_user_id IN (SELECT id FROM `user` WHERE remark LIKE '" + inviteGiftMarker + "%')", + "DELETE FROM system_logs WHERE type IN (33, 34) AND content LIKE '%" + inviteGiftMarker + "%'", + "DELETE FROM user_subscribe WHERE note = '" + inviteGiftMarker + "' OR token LIKE '" + inviteGiftMarker + "%'", + "DELETE FROM subscribe WHERE name LIKE '" + inviteGiftMarker + "%'", + "DELETE FROM `user` WHERE remark LIKE '" + inviteGiftMarker + "%'", + } + for _, stmt := range stmts { + if _, err := db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("%s: %w", stmt, err) + } + } + return nil +} + +func mustNoErr(err error) { + if err != nil { + exitf("%v", err) + } +} + +func exitf(format string, args ...interface{}) { + msg := fmt.Sprintf(format, args...) + fmt.Fprintln(os.Stderr, "FAIL:", strings.TrimSpace(msg)) + os.Exit(1) +}