fix(order): prevent duplicate subscriptions and repair invite gifts
Build docker and publish / build (20.15.1) (push) Successful in 5m6s

This commit is contained in:
2026-04-24 21:16:21 -07:00
parent ae62ecc6b3
commit 9db4762904
17 changed files with 3013 additions and 52 deletions
+197
View File
@@ -0,0 +1,197 @@
package main
import (
"database/sql"
"flag"
"fmt"
"log"
"os"
"strings"
_ "github.com/go-sql-driver/mysql"
)
func main() {
dsn := flag.String("dsn", os.Getenv("PPANEL_MYSQL_DSN"), "MySQL DSN; defaults to PPANEL_MYSQL_DSN")
flag.Parse()
if strings.TrimSpace(*dsn) == "" {
log.Fatal("missing DSN: pass -dsn or set PPANEL_MYSQL_DSN")
}
db, err := sql.Open("mysql", *dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
if err = db.Ping(); err != nil {
log.Fatal(err)
}
mustPrintRows(db, "db/info", `
SELECT NOW() AS db_now,
(SELECT COUNT(*) FROM user) AS users,
(SELECT COUNT(*) FROM user_subscribe) AS user_subscribes,
(SELECT COUNT(*) FROM `+"`order`"+`) AS orders`)
mustPrintRows(db, "bug1/confusable-email-trials", `
SELECT uam.user_id,
uam.auth_identifier,
us.id AS user_subscribe_id,
us.order_id,
us.status,
us.expire_time,
us.created_at
FROM user_auth_methods uam
JOIN user_subscribe us ON us.user_id = uam.user_id
WHERE uam.auth_type = 'email'
AND us.order_id = 0
AND (
uam.auth_identifier LIKE '%@gmaial.com'
OR uam.auth_identifier LIKE '%@gmial.com'
OR uam.auth_identifier LIKE '%@gamil.com'
OR uam.auth_identifier LIKE '%+%@%'
OR uam.auth_identifier REGEXP '^[^@]*\\.[^@]*@gmail\\.com$'
)
ORDER BY us.created_at DESC
LIMIT 50`)
mustPrintRows(db, "bug2-visible-duplicate-subscriptions", `
SELECT scoped.owner_user_id,
COUNT(*) AS visible_subscribe_count,
GROUP_CONCAT(scoped.user_subscribe_id ORDER BY scoped.expire_time DESC) AS user_subscribe_ids,
GROUP_CONCAT(scoped.subscribe_id ORDER BY scoped.expire_time DESC) AS subscribe_ids,
MAX(scoped.expire_time) AS max_expire_time
FROM (
SELECT us.id AS user_subscribe_id,
us.user_id,
COALESCE(uf.owner_user_id, us.user_id) AS owner_user_id,
us.subscribe_id,
us.status,
us.expire_time,
us.finished_at
FROM user_subscribe us
LEFT JOIN user_family_member ufm
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
LEFT JOIN user_family uf
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
WHERE us.token <> ''
AND us.status IN (0,1,2,3,4)
AND (us.expire_time > NOW()
OR us.finished_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
OR us.expire_time = FROM_UNIXTIME(0))
) scoped
GROUP BY scoped.owner_user_id
HAVING COUNT(*) > 1
ORDER BY visible_subscribe_count DESC, owner_user_id
LIMIT 50`)
mustPrintRows(db, "bug2-order-subscription-owner-mismatch", `
SELECT us.id AS user_subscribe_id,
us.user_id AS subscribe_user_id,
o.id AS order_id,
o.order_no,
o.user_id AS order_user_id,
o.subscription_user_id,
us.status,
us.expire_time,
us.created_at AS subscribe_created_at,
o.created_at AS order_created_at
FROM user_subscribe us
JOIN `+"`order`"+` o ON o.id = us.order_id
WHERE us.user_id <> o.subscription_user_id
AND us.token <> ''
AND us.status IN (0,1,2,3,4)
ORDER BY us.updated_at DESC
LIMIT 50`)
mustPrintRows(db, "bug3-invite-first-orders-missing-gift-days", `
SELECT first_orders.user_id AS referee_id,
referee.referer_id,
first_orders.id AS order_id,
first_orders.order_no,
first_orders.amount,
first_orders.created_at,
referer.referral_percentage AS referer_referral_percentage,
(SELECT COUNT(*) FROM system_logs sl
WHERE sl.type = 34
AND sl.object_id = first_orders.user_id
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) AS referee_gift_logs,
(SELECT COUNT(*) FROM system_logs sl
WHERE sl.type = 34
AND sl.object_id = referee.referer_id
AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) AS referer_gift_logs
FROM (
SELECT o.*
FROM `+"`order`"+` o
JOIN (
SELECT user_id, MIN(id) AS first_order_id
FROM `+"`order`"+`
WHERE type IN (1,2)
AND status IN (2,5)
AND amount > 0
GROUP BY user_id
) fo ON fo.first_order_id = o.id
) first_orders
JOIN user referee ON referee.id = first_orders.user_id AND referee.referer_id <> 0
JOIN user referer ON referer.id = referee.referer_id
WHERE (
referer.referral_percentage = 0
AND (
(SELECT COUNT(*) FROM system_logs sl
WHERE sl.type = 34 AND sl.object_id = first_orders.user_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
OR
(SELECT COUNT(*) FROM system_logs sl
WHERE sl.type = 34 AND sl.object_id = referee.referer_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
)
)
OR (
referer.referral_percentage > 0
AND (SELECT COUNT(*) FROM system_logs sl
WHERE sl.type = 34 AND sl.object_id = first_orders.user_id AND sl.content LIKE CONCAT('%', first_orders.order_no, '%')) = 0
)
ORDER BY first_orders.created_at DESC
LIMIT 50`)
}
func mustPrintRows(db *sql.DB, title string, query string) {
fmt.Printf("\n== %s ==\n", title)
rows, err := db.Query(query)
if err != nil {
log.Fatalf("%s: %v", title, err)
}
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
log.Fatalf("%s columns: %v", title, err)
}
fmt.Println(strings.Join(cols, "\t"))
values := make([]sql.NullString, len(cols))
args := make([]any, len(cols))
for i := range values {
args[i] = &values[i]
}
count := 0
for rows.Next() {
if err := rows.Scan(args...); err != nil {
log.Fatalf("%s scan: %v", title, err)
}
out := make([]string, len(cols))
for i, value := range values {
if value.Valid {
out[i] = value.String
} else {
out[i] = "NULL"
}
}
fmt.Println(strings.Join(out, "\t"))
count++
}
if err := rows.Err(); err != nil {
log.Fatalf("%s rows: %v", title, err)
}
if count == 0 {
fmt.Println("(none)")
}
}
+204
View File
@@ -0,0 +1,204 @@
package main
import (
"database/sql"
"encoding/json"
"flag"
"fmt"
"log"
"os"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
type duplicateGroup struct {
OwnerUserID int64 `json:"owner_user_id"`
Count int64 `json:"count"`
}
type subscriptionRow struct {
ID int64 `json:"id"`
UserID int64 `json:"user_id"`
OrderID int64 `json:"order_id"`
SubscribeID int64 `json:"subscribe_id"`
ExpireTime time.Time `json:"expire_time"`
Traffic int64 `json:"traffic"`
Download int64 `json:"download"`
Upload int64 `json:"upload"`
ExpiredDownload int64 `json:"expired_download"`
ExpiredUpload int64 `json:"expired_upload"`
Status uint8 `json:"status"`
UpdatedAt time.Time `json:"updated_at"`
}
type mergePlan struct {
OwnerUserID int64 `json:"owner_user_id"`
Keep subscriptionRow `json:"keep"`
Merge []subscriptionRow `json:"merge"`
}
func main() {
dsn := flag.String("dsn", os.Getenv("PPANEL_MYSQL_DSN"), "MySQL DSN; defaults to PPANEL_MYSQL_DSN")
execute := flag.Bool("execute", false, "apply changes; default is dry-run")
flag.Parse()
if strings.TrimSpace(*dsn) == "" {
log.Fatal("missing DSN: pass -dsn or set PPANEL_MYSQL_DSN")
}
db, err := sql.Open("mysql", *dsn)
if err != nil {
log.Fatal(err)
}
defer db.Close()
groups, err := findDuplicateGroups(db)
if err != nil {
log.Fatal(err)
}
plans := make([]mergePlan, 0, len(groups))
for _, group := range groups {
plan, err := buildPlan(db, group.OwnerUserID)
if err != nil {
log.Fatal(err)
}
if len(plan.Merge) > 0 {
plans = append(plans, plan)
}
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(plans); err != nil {
log.Fatal(err)
}
if !*execute {
fmt.Fprintf(os.Stderr, "dry-run only: %d duplicate owner groups found\n", len(plans))
return
}
for _, plan := range plans {
if err := applyPlan(db, plan); err != nil {
log.Fatal(err)
}
}
fmt.Fprintf(os.Stderr, "merged %d duplicate owner groups\n", len(plans))
}
func findDuplicateGroups(db *sql.DB) ([]duplicateGroup, error) {
rows, err := db.Query(`
SELECT owner_user_id, COUNT(1) AS cnt
FROM (
SELECT us.id,
COALESCE(uf.owner_user_id, us.user_id) AS owner_user_id
FROM user_subscribe us
LEFT JOIN user_family_member ufm
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
LEFT JOIN user_family uf
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
WHERE us.token <> ''
AND us.status IN (0, 1, 2, 3, 4)
) scoped
GROUP BY owner_user_id
HAVING COUNT(1) > 1
ORDER BY owner_user_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var groups []duplicateGroup
for rows.Next() {
var g duplicateGroup
if err := rows.Scan(&g.OwnerUserID, &g.Count); err != nil {
return nil, err
}
groups = append(groups, g)
}
return groups, rows.Err()
}
func buildPlan(db *sql.DB, ownerUserID int64) (mergePlan, error) {
rows, err := db.Query(`
SELECT us.id, us.user_id, us.order_id, us.subscribe_id, us.expire_time, us.traffic,
us.download, us.upload, us.expired_download, us.expired_upload, us.status, us.updated_at
FROM user_subscribe us
LEFT JOIN user_family_member ufm
ON ufm.user_id = us.user_id AND ufm.deleted_at IS NULL AND ufm.status = 1
LEFT JOIN user_family uf
ON uf.id = ufm.family_id AND uf.deleted_at IS NULL AND uf.status = 1
WHERE COALESCE(uf.owner_user_id, us.user_id) = ?
AND us.token <> ''
AND us.status IN (0, 1, 2, 3, 4)
ORDER BY us.expire_time DESC, us.updated_at DESC, us.id DESC`, ownerUserID)
if err != nil {
return mergePlan{}, err
}
defer rows.Close()
var all []subscriptionRow
for rows.Next() {
var r subscriptionRow
if err := rows.Scan(&r.ID, &r.UserID, &r.OrderID, &r.SubscribeID, &r.ExpireTime, &r.Traffic, &r.Download, &r.Upload, &r.ExpiredDownload, &r.ExpiredUpload, &r.Status, &r.UpdatedAt); err != nil {
return mergePlan{}, err
}
all = append(all, r)
}
if err := rows.Err(); err != nil {
return mergePlan{}, err
}
if len(all) == 0 {
return mergePlan{OwnerUserID: ownerUserID}, nil
}
keep := all[0]
for _, r := range all[1:] {
keep.Download += r.Download
keep.Upload += r.Upload
keep.ExpiredDownload += r.ExpiredDownload
keep.ExpiredUpload += r.ExpiredUpload
if r.Traffic > keep.Traffic {
keep.Traffic = r.Traffic
}
}
for _, r := range all {
if r.UpdatedAt.After(keep.UpdatedAt) {
keep.SubscribeID = r.SubscribeID
}
}
return mergePlan{OwnerUserID: ownerUserID, Keep: keep, Merge: all[1:]}, nil
}
func applyPlan(db *sql.DB, plan mergePlan) error {
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if _, err = tx.Exec(`
UPDATE user_subscribe
SET user_id = ?, subscribe_id = ?, traffic = ?, download = ?, upload = ?,
expired_download = ?, expired_upload = ?, status = 1, note = CONCAT(COALESCE(note, ''), ' [merged duplicate subscriptions]')
WHERE id = ?`,
plan.OwnerUserID, plan.Keep.SubscribeID, plan.Keep.Traffic, plan.Keep.Download, plan.Keep.Upload,
plan.Keep.ExpiredDownload, plan.Keep.ExpiredUpload, plan.Keep.ID); err != nil {
return err
}
for _, r := range plan.Merge {
if _, err = tx.Exec(`
UPDATE user_subscribe
SET status = 5, note = CONCAT(COALESCE(note, ''), ' [merged into subscription #', ?, ']')
WHERE id = ?`, plan.Keep.ID, r.ID); err != nil {
return err
}
}
return tx.Commit()
}
+787
View File
@@ -0,0 +1,787 @@
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/perfect-panel/server/internal/config"
authlogic "github.com/perfect-panel/server/internal/logic/auth"
modelLog "github.com/perfect-panel/server/internal/model/log"
modelOrder "github.com/perfect-panel/server/internal/model/order"
modelSubscribe "github.com/perfect-panel/server/internal/model/subscribe"
modelUser "github.com/perfect-panel/server/internal/model/user"
"github.com/perfect-panel/server/internal/svc"
"github.com/perfect-panel/server/pkg/conf"
"github.com/perfect-panel/server/pkg/orm"
"github.com/perfect-panel/server/pkg/uuidx"
orderLogic "github.com/perfect-panel/server/queue/logic/order"
queueTypes "github.com/perfect-panel/server/queue/types"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
const marker = "codex-replay-business-bugs"
func main() {
var (
configPath = flag.String("config", "etc/ppanel.yaml", "ppanel config path for test server DB/Redis")
dsn = flag.String("dsn", "", "optional MySQL DSN override: user:pass@tcp(host:3306)/db?charset=utf8mb4&parseTime=true&loc=Asia%2FShanghai")
writeDB = flag.Bool("write-db", false, "create isolated test rows and execute activation replay against the configured test DB")
force = flag.Bool("force", false, "allow -write-db even when the config name does not clearly look like test/dev/staging")
keep = flag.Bool("keep", false, "keep replay rows for manual inspection")
cleanupOnly = flag.Bool("cleanup-only", false, "delete leftover replay rows by marker and exit")
skipCodeTests = flag.Bool("skip-code-tests", false, "skip go test checks")
)
flag.Parse()
ctx := context.Background()
started := time.Now()
fmt.Println("== replay business bug tests ==")
fmt.Printf("marker: %s\n", marker)
if !*skipCodeTests {
must(runCodeTests())
}
cfg := loadConfig(*configPath, *dsn)
runEmailTrialAssertions(cfg)
if *cleanupOnly {
env := mustNewReplayEnv(ctx, cfg)
env.cleanupByMarker(ctx)
return
}
if !*writeDB {
fmt.Println("\nDB replay skipped. Add -write-db to create isolated rows in the TEST database and run activation flows.")
fmt.Println("Example:")
fmt.Printf(" go run scripts/replay_business_bugs.go -config %s -write-db\n", *configPath)
return
}
if looksLikeProduction(cfg) && !*force {
fatalf("refusing to write DB because config does not look like a test environment: db=%s host=%s; add -force only on the test server", cfg.MySQL.Dbname, cfg.Site.Host)
}
env := mustNewReplayEnv(ctx, cfg)
if !*keep {
defer env.cleanup(ctx)
}
must(env.replaySingleSubscription(ctx))
must(env.replayInviteRulesMatrix(ctx))
must(env.replayFamilyInviteGiftToOwner(ctx))
fmt.Printf("\nPASS all replay checks in %s\n", time.Since(started).Round(time.Millisecond))
if *keep {
fmt.Println("Replay rows kept for inspection. Delete rows with remark/name/order_no containing:", marker)
}
}
func runCodeTests() error {
fmt.Println("\n-- code-level tests --")
args := []string{"test",
"./internal/logic/auth",
"./internal/logic/common",
"./internal/logic/public/order",
"./queue/logic/order",
}
cmd := exec.Command("go", args...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
return fmt.Errorf("go test failed: %w", err)
}
fmt.Println("PASS code-level tests")
return nil
}
func loadConfig(path, dsn string) config.Config {
var cfg config.Config
conf.MustLoad(path, &cfg)
if dsn != "" {
cfg.MySQL = parseDSN(dsn)
}
return cfg
}
func parseDSN(dsn string) orm.Config {
cfg := orm.ParseDSN(dsn)
if cfg == nil {
fatalf("invalid dsn")
}
return *cfg
}
func runEmailTrialAssertions(cfg config.Config) {
fmt.Println("\n-- bug1 email trial whitelist assertions --")
cfg.Register.EnableTrial = true
cfg.Register.EnableTrialEmailWhitelist = true
if cfg.Register.TrialEmailDomainWhitelist == "" {
cfg.Register.TrialEmailDomainWhitelist = "gmail.com,163.com"
}
cases := []struct {
email string
want bool
}{
{"1.2.3.4xxx@gmaial.com", false},
{"a.b.c@gmail.com", false},
{"user+tag@gmail.com", false},
{"user@fake.gmail.com", false},
{"normaluser@gmail.com", true},
}
for _, tc := range cases {
got := authlogic.ShouldGrantTrialForEmail(cfg.Register, tc.email)
if got != tc.want {
fatalf("email trial assertion failed: email=%s got=%v want=%v", tc.email, got, tc.want)
}
fmt.Printf("PASS %-32s grant=%v\n", tc.email, got)
}
}
type replayEnv struct {
db *gorm.DB
rds *redis.Client
cfg config.Config
svcCtx *svc.ServiceContext
ids struct {
users []int64
subscribes []int64
plans []int64
orders []int64
logs []int64
}
}
func mustNewReplayEnv(ctx context.Context, cfg config.Config) *replayEnv {
fmt.Println("\n-- connecting test DB/Redis --")
db, err := orm.ConnectMysql(orm.Mysql{Config: cfg.MySQL})
must(err)
rds := redis.NewClient(&redis.Options{
Addr: cfg.Redis.Host,
Password: cfg.Redis.Pass,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
})
must(rds.Ping(ctx).Err())
svcCtx := &svc.ServiceContext{
DB: db,
Redis: rds,
Config: cfg,
UserModel: modelUser.NewModel(db, rds),
OrderModel: modelOrder.NewModel(db, rds),
SubscribeModel: modelSubscribe.NewModel(db, rds),
LogModel: modelLog.NewModel(db),
}
fmt.Printf("connected: mysql=%s/%s redis=%s\n", cfg.MySQL.Addr, cfg.MySQL.Dbname, cfg.Redis.Host)
return &replayEnv{db: db, rds: rds, cfg: cfg, svcCtx: svcCtx}
}
func (e *replayEnv) replaySingleSubscription(ctx context.Context) error {
fmt.Println("\n-- bug2 replay: paid purchase must reuse existing subscription --")
planA, planB, err := e.createPlans(ctx, "bug2")
if err != nil {
return err
}
owner, err := e.createUser(ctx, "bug2-owner", 0, 0)
if err != nil {
return err
}
existing, err := e.createUserSubscribe(ctx, owner.Id, 0, planA.Id, time.Now().Add(7*24*time.Hour))
if err != nil {
return err
}
order, err := e.createPaidOrder(ctx, owner.Id, owner.Id, planB.Id, true, "bug2")
if err != nil {
return err
}
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: order.OrderNo})
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
return err
}
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
return err
}
var rows []modelUser.Subscribe
if err = e.db.WithContext(ctx).
Where("user_id = ? AND token <> '' AND status IN ?", owner.Id, []int{0, 1, 2, 3, 4}).
Order("id ASC").
Find(&rows).Error; err != nil {
return err
}
if len(rows) != 1 {
return fmt.Errorf("bug2 failed: expected one visible subscription, got %d", len(rows))
}
if rows[0].Id != existing.Id {
return fmt.Errorf("bug2 failed: expected original subscription id=%d to be reused, got id=%d", existing.Id, rows[0].Id)
}
if rows[0].SubscribeId != planB.Id || rows[0].OrderId != order.Id {
return fmt.Errorf("bug2 failed: reused subscription not updated, subscribe_id=%d order_id=%d", rows[0].SubscribeId, rows[0].OrderId)
}
fmt.Printf("PASS user=%d user_subscribe=%d plan %d -> %d order=%s\n", owner.Id, rows[0].Id, planA.Id, planB.Id, order.OrderNo)
return nil
}
func (e *replayEnv) replayInviteGiftDays(ctx context.Context) error {
fmt.Println("\n-- bug3 replay: commission=0 invite should grant gift days to both users --")
giftDays := e.cfg.Invite.GiftDays
if giftDays <= 0 {
giftDays = 2
e.svcCtx.Config.Invite.GiftDays = giftDays
}
e.svcCtx.Config.Invite.ReferralPercentage = 0
e.svcCtx.Config.Invite.OnlyFirstPurchase = true
planA, _, err := e.createPlans(ctx, "bug3")
if err != nil {
return err
}
referer, err := e.createUser(ctx, "bug3-referer", 0, 0)
if err != nil {
return err
}
referee, err := e.createUser(ctx, "bug3-referee", referer.Id, 0)
if err != nil {
return err
}
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Millisecond)
refererSub, err := e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, baseExpire)
if err != nil {
return err
}
refereeSub, err := e.createUserSubscribe(ctx, referee.Id, 0, planA.Id, baseExpire)
if err != nil {
return err
}
order, err := e.createPaidOrder(ctx, referee.Id, referee.Id, planA.Id, true, "bug3")
if err != nil {
return err
}
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: order.OrderNo})
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
return err
}
if err = e.waitForGiftLogs(ctx, order.OrderNo, referer.Id, referee.Id); err != nil {
return err
}
var refererAfter, refereeAfter modelUser.Subscribe
if err = e.db.WithContext(ctx).First(&refererAfter, refererSub.Id).Error; err != nil {
return err
}
if err = e.db.WithContext(ctx).First(&refereeAfter, refereeSub.Id).Error; err != nil {
return err
}
minRefererExpire := baseExpire.Add(time.Duration(giftDays) * 24 * time.Hour)
if refererAfter.ExpireTime.Before(minRefererExpire.Add(-time.Second)) {
return fmt.Errorf("bug3 failed: referer expire not increased by gift days, got=%s want>=%s", refererAfter.ExpireTime, minRefererExpire)
}
if !refereeAfter.ExpireTime.After(baseExpire) {
return fmt.Errorf("bug3 failed: referee expire did not increase, got=%s base=%s", refereeAfter.ExpireTime, baseExpire)
}
// Idempotency: repeat the same order task and make sure gift logs are still one per user.
if err = worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
return err
}
var giftCount int64
if err = e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
Where("type = ? AND object_id IN ? AND content LIKE ?", modelLog.TypeGift.Uint8(), []int64{referer.Id, referee.Id}, "%"+order.OrderNo+"%").
Count(&giftCount).Error; err != nil {
return err
}
if giftCount != 2 {
return fmt.Errorf("bug3 failed: expected 2 gift logs after duplicate task, got %d", giftCount)
}
fmt.Printf("PASS referer=%d referee=%d order=%s gift_days=%d logs=%d\n", referer.Id, referee.Id, order.OrderNo, giftDays, giftCount)
return nil
}
func (e *replayEnv) replayInviteRulesMatrix(ctx context.Context) error {
fmt.Println("\n-- bug3 replay matrix: invite gift/commission rules --")
giftDays := e.cfg.Invite.GiftDays
if giftDays <= 0 {
giftDays = 2
}
e.svcCtx.Config.Invite.GiftDays = giftDays
e.svcCtx.Config.Invite.OnlyFirstPurchase = false
planA, _, err := e.createPlans(ctx, "bug3-matrix")
if err != nil {
return err
}
cases := []struct {
name string
hasReferer bool
globalReferralPct int64
isNewOrder bool
wantGiftLogs int64
wantCommissionLogs int64
wantCommission int64
}{
{
name: "no invite relation first order no gift",
hasReferer: false,
isNewOrder: true,
wantGiftLogs: 0,
},
{
name: "ordinary invite commission 0 first order gifts both",
hasReferer: true,
isNewOrder: true,
wantGiftLogs: 2,
},
{
name: "ordinary invite commission 0 non-first order no gift",
hasReferer: true,
isNewOrder: false,
wantGiftLogs: 0,
},
{
name: "channel commission positive first order gifts referee only",
hasReferer: true,
globalReferralPct: 10,
isNewOrder: true,
wantGiftLogs: 1,
wantCommissionLogs: 1,
wantCommission: 59,
},
{
name: "channel commission positive non-first order commission only",
hasReferer: true,
globalReferralPct: 10,
isNewOrder: false,
wantGiftLogs: 0,
wantCommissionLogs: 1,
wantCommission: 59,
},
}
for idx, tc := range cases {
e.svcCtx.Config.Invite.ReferralPercentage = tc.globalReferralPct
scope := fmt.Sprintf("bug3-rule-%d", idx+1)
var referer *modelUser.User
if tc.hasReferer {
referer, err = e.createUser(ctx, scope+"-referer", 0, 0)
if err != nil {
return err
}
if _, err = e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, time.Now().Add(10*24*time.Hour)); err != nil {
return err
}
}
var refererID int64
if referer != nil {
refererID = referer.Id
}
referee, err := e.createUser(ctx, scope+"-referee", refererID, 0)
if err != nil {
return err
}
if _, err = e.createUserSubscribe(ctx, referee.Id, 0, planA.Id, time.Now().Add(10*24*time.Hour)); err != nil {
return err
}
order, err := e.createPaidOrder(ctx, referee.Id, referee.Id, planA.Id, tc.isNewOrder, scope)
if err != nil {
return err
}
if err = e.activateOrderTwice(ctx, order.OrderNo); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
if err = e.waitForLogCounts(ctx, order.OrderNo, tc.wantGiftLogs, tc.wantCommissionLogs); err != nil {
return fmt.Errorf("%s: %w", tc.name, err)
}
giftLogs, err := e.countLogs(ctx, modelLog.TypeGift.Uint8(), order.OrderNo)
if err != nil {
return err
}
commissionLogs, err := e.countLogs(ctx, modelLog.TypeCommission.Uint8(), order.OrderNo)
if err != nil {
return err
}
if giftLogs != tc.wantGiftLogs {
return fmt.Errorf("%s: expected gift logs=%d got=%d", tc.name, tc.wantGiftLogs, giftLogs)
}
if commissionLogs != tc.wantCommissionLogs {
return fmt.Errorf("%s: expected commission logs=%d got=%d", tc.name, tc.wantCommissionLogs, commissionLogs)
}
if referer != nil && tc.wantCommission > 0 {
var after modelUser.User
if err = e.db.WithContext(ctx).First(&after, referer.Id).Error; err != nil {
return err
}
if after.Commission != tc.wantCommission {
return fmt.Errorf("%s: expected referer commission=%d got=%d", tc.name, tc.wantCommission, after.Commission)
}
}
fmt.Printf("PASS %-58s gifts=%d commission_logs=%d\n", tc.name, giftLogs, commissionLogs)
}
return nil
}
func (e *replayEnv) replayFamilyInviteGiftToOwner(ctx context.Context) error {
fmt.Println("\n-- bug3 family replay: member purchase gift days go to owner --")
giftDays := e.cfg.Invite.GiftDays
if giftDays <= 0 {
giftDays = 2
}
e.svcCtx.Config.Invite.GiftDays = giftDays
e.svcCtx.Config.Invite.ReferralPercentage = 0
e.svcCtx.Config.Invite.OnlyFirstPurchase = false
planA, _, err := e.createPlans(ctx, "bug3-family")
if err != nil {
return err
}
referer, err := e.createUser(ctx, "bug3-family-referer", 0, 0)
if err != nil {
return err
}
owner, err := e.createUser(ctx, "bug3-family-owner", 0, 0)
if err != nil {
return err
}
member, err := e.createUser(ctx, "bug3-family-member", referer.Id, 0)
if err != nil {
return err
}
if err = e.createFamily(ctx, owner.Id, member.Id); err != nil {
return err
}
baseExpire := time.Now().Add(10 * 24 * time.Hour).Truncate(time.Millisecond)
ownerSub, err := e.createUserSubscribe(ctx, owner.Id, 0, planA.Id, baseExpire)
if err != nil {
return err
}
memberSub, err := e.createUserSubscribe(ctx, member.Id, 0, planA.Id, baseExpire)
if err != nil {
return err
}
refererSub, err := e.createUserSubscribe(ctx, referer.Id, 0, planA.Id, baseExpire)
if err != nil {
return err
}
order, err := e.createPaidOrder(ctx, member.Id, owner.Id, planA.Id, true, "bug3-family")
if err != nil {
return err
}
if err = e.activateOrderTwice(ctx, order.OrderNo); err != nil {
return err
}
if err = e.waitForLogCounts(ctx, order.OrderNo, 2, 0); err != nil {
return err
}
var ownerAfter, memberAfter, refererAfter modelUser.Subscribe
if err = e.db.WithContext(ctx).First(&ownerAfter, ownerSub.Id).Error; err != nil {
return err
}
if err = e.db.WithContext(ctx).First(&memberAfter, memberSub.Id).Error; err != nil {
return err
}
if err = e.db.WithContext(ctx).First(&refererAfter, refererSub.Id).Error; err != nil {
return err
}
if !ownerAfter.ExpireTime.After(baseExpire) {
return fmt.Errorf("family gift failed: owner expire not increased")
}
if !refererAfter.ExpireTime.After(baseExpire) {
return fmt.Errorf("family gift failed: referer expire not increased")
}
if memberAfter.ExpireTime.After(baseExpire.Add(time.Second)) {
return fmt.Errorf("family gift failed: member subscription should not receive gift days")
}
var memberGiftLogs int64
if err = e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
Where("type = ? AND object_id = ? AND content LIKE ?", modelLog.TypeGift.Uint8(), member.Id, "%"+order.OrderNo+"%").
Count(&memberGiftLogs).Error; err != nil {
return err
}
if memberGiftLogs != 0 {
return fmt.Errorf("family gift failed: expected no member gift logs, got %d", memberGiftLogs)
}
fmt.Printf("PASS family member purchase gift target owner owner=%d member=%d referer=%d gift_days=%d\n", owner.Id, member.Id, referer.Id, giftDays)
return nil
}
func (e *replayEnv) activateOrderTwice(ctx context.Context, orderNo string) error {
payload, _ := json.Marshal(queueTypes.ForthwithActivateOrderPayload{OrderNo: orderNo})
worker := orderLogic.NewActivateOrderLogic(e.svcCtx)
if err := worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload)); err != nil {
return err
}
return worker.ProcessTask(ctx, asynq.NewTask(queueTypes.ForthwithActivateOrder, payload))
}
func (e *replayEnv) waitForLogCounts(ctx context.Context, orderNo string, wantGiftLogs, wantCommissionLogs int64) error {
deadline := time.Now().Add(8 * time.Second)
for {
giftLogs, err := e.countLogs(ctx, modelLog.TypeGift.Uint8(), orderNo)
if err != nil {
return err
}
commissionLogs, err := e.countLogs(ctx, modelLog.TypeCommission.Uint8(), orderNo)
if err != nil {
return err
}
if giftLogs >= wantGiftLogs && commissionLogs >= wantCommissionLogs {
if wantGiftLogs == 0 && wantCommissionLogs == 0 {
time.Sleep(500 * time.Millisecond)
}
return nil
}
if time.Now().After(deadline) {
return fmt.Errorf("timed out waiting for logs: order=%s gift=%d/%d commission=%d/%d", orderNo, giftLogs, wantGiftLogs, commissionLogs, wantCommissionLogs)
}
time.Sleep(100 * time.Millisecond)
}
}
func (e *replayEnv) countLogs(ctx context.Context, logType uint8, orderNo string) (int64, error) {
var count int64
err := e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
Where("type = ? AND content LIKE ?", logType, "%"+orderNo+"%").
Count(&count).Error
return count, err
}
func (e *replayEnv) waitForGiftLogs(ctx context.Context, orderNo string, userIDs ...int64) error {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
var count int64
if err := e.db.WithContext(ctx).Model(&modelLog.SystemLog{}).
Where("type = ? AND object_id IN ? AND content LIKE ?", modelLog.TypeGift.Uint8(), userIDs, "%"+orderNo+"%").
Count(&count).Error; err != nil {
return err
}
if count == int64(len(userIDs)) {
return nil
}
time.Sleep(100 * time.Millisecond)
}
return fmt.Errorf("timed out waiting for gift logs for order=%s", orderNo)
}
func (e *replayEnv) createPlans(ctx context.Context, scope string) (*modelSubscribe.Subscribe, *modelSubscribe.Subscribe, error) {
a := &modelSubscribe.Subscribe{
Name: marker + "-" + scope + "-A",
Language: "en",
UnitPrice: 599,
UnitTime: "Month",
Traffic: 1024 * 1024 * 1024,
Inventory: -1,
Quota: 0,
NodeGroupIds: modelSubscribe.JSONInt64Slice{},
}
b := &modelSubscribe.Subscribe{
Name: marker + "-" + scope + "-B",
Language: "en",
UnitPrice: 699,
UnitTime: "Month",
Traffic: 2 * 1024 * 1024 * 1024,
Inventory: -1,
Quota: 0,
NodeGroupIds: modelSubscribe.JSONInt64Slice{},
}
if err := e.db.WithContext(ctx).Create(a).Error; err != nil {
return nil, nil, err
}
if err := e.db.WithContext(ctx).Create(b).Error; err != nil {
return nil, nil, err
}
e.ids.plans = append(e.ids.plans, a.Id, b.Id)
return a, b, nil
}
func (e *replayEnv) createUser(ctx context.Context, scope string, refererID int64, referralPercentage uint8) (*modelUser.User, error) {
onlyFirst := true
enable := true
isAdmin := false
u := &modelUser.User{
Password: marker,
Algo: "default",
Salt: "default",
RefererId: refererID,
ReferralPercentage: referralPercentage,
OnlyFirstPurchase: &onlyFirst,
Enable: &enable,
IsAdmin: &isAdmin,
EnableBalanceNotify: &enable,
EnableLoginNotify: &enable,
EnableSubscribeNotify: &enable,
EnableTradeNotify: &enable,
Remark: marker + "-" + scope,
}
if err := e.db.WithContext(ctx).Create(u).Error; err != nil {
return nil, err
}
u.ReferCode = uuidx.UserInviteCode(u.Id)
if err := e.db.WithContext(ctx).Model(&modelUser.User{}).Where("id = ?", u.Id).Update("refer_code", u.ReferCode).Error; err != nil {
return nil, err
}
e.ids.users = append(e.ids.users, u.Id)
return u, nil
}
func (e *replayEnv) createFamily(ctx context.Context, ownerID, memberID int64) error {
now := time.Now()
family := &modelUser.UserFamily{
OwnerUserId: ownerID,
MaxMembers: modelUser.DefaultFamilyMaxSize,
Status: modelUser.FamilyStatusActive,
}
if err := e.db.WithContext(ctx).Create(family).Error; err != nil {
return err
}
members := []modelUser.UserFamilyMember{
{
FamilyId: family.Id,
UserId: ownerID,
Role: modelUser.FamilyRoleOwner,
Status: modelUser.FamilyMemberActive,
JoinSource: marker,
JoinedAt: now,
},
{
FamilyId: family.Id,
UserId: memberID,
Role: modelUser.FamilyRoleMember,
Status: modelUser.FamilyMemberActive,
JoinSource: marker,
JoinedAt: now,
},
}
return e.db.WithContext(ctx).Create(&members).Error
}
func (e *replayEnv) createUserSubscribe(ctx context.Context, userID, orderID, planID int64, expire time.Time) (*modelUser.Subscribe, error) {
groupLocked := false
sub := &modelUser.Subscribe{
UserId: userID,
OrderId: orderID,
SubscribeId: planID,
GroupLocked: &groupLocked,
StartTime: time.Now().Add(-time.Hour),
ExpireTime: expire,
Traffic: 1024 * 1024 * 1024,
Token: marker + "-" + uuidx.NewUUID().String(),
UUID: uuidx.NewUUID().String(),
Status: 1,
Note: marker,
}
if err := e.db.WithContext(ctx).Create(sub).Error; err != nil {
return nil, err
}
e.ids.subscribes = append(e.ids.subscribes, sub.Id)
return sub, nil
}
func (e *replayEnv) createPaidOrder(ctx context.Context, userID, subscriptionUserID, planID int64, isNew bool, scope string) (*modelOrder.Order, error) {
orderNo := fmt.Sprintf("%s-%s-%d", marker, scope, time.Now().UnixNano())
order := &modelOrder.Order{
UserId: userID,
SubscriptionUserId: subscriptionUserID,
OrderNo: orderNo,
Type: 1,
Quantity: 1,
Price: 599,
Amount: 599,
Status: 2,
SubscribeId: planID,
Method: "replay",
IsNew: isNew,
}
if err := e.db.WithContext(ctx).Create(order).Error; err != nil {
return nil, err
}
e.ids.orders = append(e.ids.orders, order.Id)
return order, nil
}
func (e *replayEnv) cleanup(ctx context.Context) {
fmt.Println("\n-- cleanup replay rows --")
e.cleanupByMarker(ctx)
if len(e.ids.subscribes) > 0 {
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.subscribes).Delete(&modelUser.Subscribe{}).Error
}
if len(e.ids.orders) > 0 {
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.orders).Delete(&modelOrder.Order{}).Error
}
if len(e.ids.plans) > 0 {
_ = e.db.WithContext(ctx).Where("id IN ?", e.ids.plans).Delete(&modelSubscribe.Subscribe{}).Error
}
if len(e.ids.users) > 0 {
_ = e.db.WithContext(ctx).Unscoped().Where("id IN ?", e.ids.users).Delete(&modelUser.User{}).Error
}
fmt.Println("cleanup done")
}
func (e *replayEnv) cleanupByMarker(ctx context.Context) {
_ = e.db.WithContext(ctx).
Where("join_source = ?", marker).
Delete(&modelUser.UserFamilyMember{}).Error
_ = e.db.WithContext(ctx).
Where("owner_user_id IN (SELECT id FROM `user` WHERE remark LIKE ?)", marker+"%").
Delete(&modelUser.UserFamily{}).Error
_ = e.db.WithContext(ctx).
Where("type IN (33, 34) AND content LIKE ?", "%"+marker+"%").
Delete(&modelLog.SystemLog{}).Error
_ = e.db.WithContext(ctx).
Where("order_no LIKE ?", marker+"%").
Delete(&modelOrder.Order{}).Error
_ = e.db.WithContext(ctx).
Where("note = ? OR token LIKE ?", marker, marker+"%").
Delete(&modelUser.Subscribe{}).Error
_ = e.db.WithContext(ctx).
Where("name LIKE ?", marker+"%").
Delete(&modelSubscribe.Subscribe{}).Error
_ = e.db.WithContext(ctx).Unscoped().
Where("remark LIKE ?", marker+"%").
Delete(&modelUser.User{}).Error
}
func looksLikeProduction(cfg config.Config) bool {
joined := strings.ToLower(strings.Join([]string{cfg.MySQL.Dbname, cfg.Site.Host, cfg.Host}, " "))
if strings.Contains(joined, "prod") || strings.Contains(joined, "production") {
return true
}
if cfg.Debug {
return false
}
if strings.Contains(joined, "test") || strings.Contains(joined, "dev") || strings.Contains(joined, "staging") {
return false
}
return true
}
func must(err error) {
if err != nil {
fatalf("%v", err)
}
}
func fatalf(format string, args ...interface{}) {
fmt.Fprintf(os.Stderr, "FAIL: "+format+"\n", args...)
os.Exit(1)
}