Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ab38cd4943 | |||
| 5b49aa8242 | |||
| 9db4762904 | |||
| ae62ecc6b3 | |||
| 4b73cd4d3c | |||
| 2c9833df58 | |||
| 23a7a292ef | |||
| f1bfc78d66 | |||
| 9912df9ac6 | |||
| bafb13cf06 | |||
| 9a8ae8b6fd | |||
| c8258dc93b | |||
| c0d839deb9 | |||
| 800f9c8460 | |||
| 954b19c332 |
@ -28,7 +28,7 @@ FROM scratch
|
|||||||
|
|
||||||
# Copy CA certificates and timezone data
|
# Copy CA certificates and timezone data
|
||||||
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
|
||||||
COPY --from=builder /usr/share/zoneinfo/Asia/Shanghai /usr/share/zoneinfo/Asia/Shanghai
|
COPY --from=builder /usr/share/zoneinfo /usr/share/zoneinfo
|
||||||
|
|
||||||
ENV TZ=Asia/Shanghai
|
ENV TZ=Asia/Shanghai
|
||||||
|
|
||||||
|
|||||||
@ -149,11 +149,12 @@ type (
|
|||||||
State string `form:"state"`
|
State string `form:"state"`
|
||||||
}
|
}
|
||||||
DeviceLoginRequest {
|
DeviceLoginRequest {
|
||||||
Identifier string `json:"identifier" validate:"required"`
|
Identifier string `json:"identifier" validate:"required"`
|
||||||
IP string `header:"X-Original-Forwarded-For"`
|
IP string `header:"X-Original-Forwarded-For"`
|
||||||
UserAgent string `json:"user_agent" validate:"required"`
|
UserAgent string `json:"user_agent" validate:"required"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
ShortCode string `json:"short_code,optional"`
|
ShortCode string `json:"short_code,optional"`
|
||||||
|
BasePayload string `json:"base_payload,optional"`
|
||||||
}
|
}
|
||||||
GenerateCaptchaResponse {
|
GenerateCaptchaResponse {
|
||||||
Id string `json:"id"`
|
Id string `json:"id"`
|
||||||
|
|||||||
@ -227,6 +227,7 @@ type (
|
|||||||
SubscribeDiscount {
|
SubscribeDiscount {
|
||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount float64 `json:"discount"`
|
||||||
|
MapApple string `json:"map_apple"`
|
||||||
}
|
}
|
||||||
TrafficLimit {
|
TrafficLimit {
|
||||||
StatType string `json:"stat_type"`
|
StatType string `json:"stat_type"`
|
||||||
|
|||||||
BIN
debug_device_login
Executable file
BIN
debug_device_login
Executable file
Binary file not shown.
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_device` DROP COLUMN `base_payload`;
|
||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE `user_device` ADD COLUMN `base_payload` TEXT DEFAULT NULL COMMENT 'Base Payload' AFTER `short_code`;
|
||||||
@ -96,6 +96,17 @@ func (l *DeviceLoginLogic) DeviceLogin(req *types.DeviceLoginRequest) (resp *typ
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "query user failed: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update base_payload if provided
|
||||||
|
if req.BasePayload != "" && req.BasePayload != deviceInfo.BasePayload {
|
||||||
|
deviceInfo.BasePayload = req.BasePayload
|
||||||
|
if updateErr := l.svcCtx.UserModel.UpdateDevice(l.ctx, deviceInfo); updateErr != nil {
|
||||||
|
l.Errorw("update device base_payload failed",
|
||||||
|
logger.Field("device_id", deviceInfo.Id),
|
||||||
|
logger.Field("error", updateErr.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 注销后 device auth_method 被删除,重新登录时需要补回
|
// 注销后 device auth_method 被删除,重新登录时需要补回
|
||||||
hasDeviceAuth := false
|
hasDeviceAuth := false
|
||||||
for _, am := range userInfo.AuthMethods {
|
for _, am := range userInfo.AuthMethods {
|
||||||
@ -220,13 +231,14 @@ func (l *DeviceLoginLogic) registerUserAndDevice(req *types.DeviceLoginRequest)
|
|||||||
|
|
||||||
// Insert device record
|
// Insert device record
|
||||||
deviceInfo := &user.Device{
|
deviceInfo := &user.Device{
|
||||||
Ip: req.IP,
|
Ip: req.IP,
|
||||||
UserId: userInfo.Id,
|
UserId: userInfo.Id,
|
||||||
UserAgent: req.UserAgent,
|
UserAgent: req.UserAgent,
|
||||||
Identifier: req.Identifier,
|
Identifier: req.Identifier,
|
||||||
ShortCode: req.ShortCode,
|
ShortCode: req.ShortCode,
|
||||||
Enabled: true,
|
BasePayload: req.BasePayload,
|
||||||
Online: false,
|
Enabled: true,
|
||||||
|
Online: false,
|
||||||
}
|
}
|
||||||
if err := db.Create(deviceInfo).Error; err != nil {
|
if err := db.Create(deviceInfo).Error; err != nil {
|
||||||
l.Errorw("failed to insert device",
|
l.Errorw("failed to insert device",
|
||||||
|
|||||||
@ -126,7 +126,9 @@ func (l *EmailLoginLogic) EmailLogin(req *types.EmailLoginRequest) (resp *types.
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
if ShouldGrantTrialForEmail(rc, req.Email) {
|
if ShouldAutoGrantTrialOnPublicEmailFlows(rc) &&
|
||||||
|
ShouldGrantTrialForEmail(rc, req.Email) &&
|
||||||
|
!NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) {
|
||||||
if err = l.activeTrial(userInfo.Id); err != nil {
|
if err = l.activeTrial(userInfo.Id); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@ -396,7 +396,10 @@ func (l *OAuthLoginGetTokenLogic) register(email, avatar, method, openid, reques
|
|||||||
}
|
}
|
||||||
|
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
shouldActivateTrial := email != "" && authlogic.ShouldGrantTrialForEmail(rc, email)
|
shouldActivateTrial := email != "" &&
|
||||||
|
authlogic.ShouldAutoGrantTrialOnPublicEmailFlows(rc) &&
|
||||||
|
authlogic.ShouldGrantTrialForEmail(rc, email) &&
|
||||||
|
!authlogic.NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, email, rc.TrialSubscribe)
|
||||||
|
|
||||||
if shouldActivateTrial {
|
if shouldActivateTrial {
|
||||||
l.Debugw("activating trial subscription",
|
l.Debugw("activating trial subscription",
|
||||||
|
|||||||
@ -1,9 +1,13 @@
|
|||||||
package auth
|
package auth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"net/mail"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
|
// IsEmailDomainWhitelisted checks if the email's domain is in the comma-separated whitelist.
|
||||||
@ -12,11 +16,10 @@ func IsEmailDomainWhitelisted(email, whitelistCSV string) bool {
|
|||||||
if whitelistCSV == "" {
|
if whitelistCSV == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
parts := strings.SplitN(email, "@", 2)
|
_, domain, ok := parseStrictEmail(email)
|
||||||
if len(parts) != 2 {
|
if !ok {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
domain := strings.ToLower(strings.TrimSpace(parts[1]))
|
|
||||||
for _, d := range strings.Split(whitelistCSV, ",") {
|
for _, d := range strings.Split(whitelistCSV, ",") {
|
||||||
if strings.ToLower(strings.TrimSpace(d)) == domain {
|
if strings.ToLower(strings.TrimSpace(d)) == domain {
|
||||||
return true
|
return true
|
||||||
@ -29,11 +32,170 @@ func ShouldGrantTrialForEmail(register config.RegisterConfig, email string) bool
|
|||||||
if !register.EnableTrial {
|
if !register.EnableTrial {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if !IsValidTrialEmail(email) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// 无论白名单是否启用,泛域名邮箱(含 + 别名或 Gmail 点号)始终拒绝赠送
|
||||||
|
if IsDisposableAlias(email) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if isConfusableGmailDomain(emailDomain(email)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
if !register.EnableTrialEmailWhitelist {
|
if !register.EnableTrialEmailWhitelist {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if register.TrialEmailDomainWhitelist == "" {
|
if register.TrialEmailDomainWhitelist == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist)
|
if !IsEmailDomainWhitelisted(email, register.TrialEmailDomainWhitelist) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// ShouldAutoGrantTrialOnPublicEmailFlows defines whether browser/email-originated
|
||||||
|
// flows may auto-create a trial subscription. The current policy disables trial
|
||||||
|
// creation for email registration, email login auto-register, OAuth-with-email,
|
||||||
|
// and email binding/verification to avoid abuse through public email channels.
|
||||||
|
func ShouldAutoGrantTrialOnPublicEmailFlows(register config.RegisterConfig) bool {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsDisposableAlias detects Gmail dot trick and + alias abuse.
|
||||||
|
// For Gmail-like domains, local part containing "." or "+" is rejected.
|
||||||
|
// For all other domains, only "+" alias is rejected.
|
||||||
|
func IsDisposableAlias(email string) bool {
|
||||||
|
local, domain, ok := parseStrictEmail(email)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// All domains: reject + alias
|
||||||
|
if strings.ContainsRune(local, '+') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
// Gmail-like domains: reject dots in local part
|
||||||
|
if isGmailLikeDomain(domain) && strings.ContainsRune(local, '.') {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeEmail returns a canonical form of the email for trial deduplication.
|
||||||
|
// Strips "+" aliases universally (user+tag@any.com → user@any.com).
|
||||||
|
// Removes dots from local part for Gmail-like providers (gmail.com, googlemail.com).
|
||||||
|
func NormalizeEmail(email string) string {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
local, domain, ok := parseStrictEmail(email)
|
||||||
|
if !ok {
|
||||||
|
return email
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip + alias
|
||||||
|
if idx := strings.IndexByte(local, '+'); idx != -1 {
|
||||||
|
local = local[:idx]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove dots for Gmail-like providers that ignore dots in local part
|
||||||
|
if isGmailLikeDomain(domain) {
|
||||||
|
local = strings.ReplaceAll(local, ".", "")
|
||||||
|
}
|
||||||
|
|
||||||
|
return local + "@" + domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func isGmailLikeDomain(domain string) bool {
|
||||||
|
switch domain {
|
||||||
|
case "gmail.com", "googlemail.com":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsValidTrialEmail(email string) bool {
|
||||||
|
local, domain, ok := parseStrictEmail(email)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return local != "" && domain != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseStrictEmail(email string) (local, domain string, ok bool) {
|
||||||
|
email = strings.ToLower(strings.TrimSpace(email))
|
||||||
|
if email == "" || strings.ContainsAny(email, " \t\r\n") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
addr, err := mail.ParseAddress(email)
|
||||||
|
if err != nil || addr.Address != email || addr.Name != "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
parts := strings.Split(addr.Address, "@")
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
local = strings.TrimSpace(parts[0])
|
||||||
|
domain = strings.Trim(strings.TrimSpace(parts[1]), ".")
|
||||||
|
if local == "" || domain == "" || strings.Contains(domain, "..") || !strings.Contains(domain, ".") {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return local, domain, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func emailDomain(email string) string {
|
||||||
|
_, domain, ok := parseStrictEmail(email)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
func isConfusableGmailDomain(domain string) bool {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(domain)) {
|
||||||
|
case "gmaial.com", "gmial.com", "gmai.com", "gamil.com", "gmal.com", "gmail.co", "gmail.con":
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizedEmailHasTrial returns true if any user with the same normalized email
|
||||||
|
// already holds a trial subscription. Only performs the cross-user DB check when
|
||||||
|
// normalization actually changes the email (i.e., dots removed or + alias stripped).
|
||||||
|
func NormalizedEmailHasTrial(ctx context.Context, db *gorm.DB, email string, trialSubscribeId int64) bool {
|
||||||
|
normalized := NormalizeEmail(email)
|
||||||
|
if normalized == strings.ToLower(strings.TrimSpace(email)) {
|
||||||
|
return false // normalization changed nothing, skip cross-user check
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(normalized, "@", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
domain := parts[1]
|
||||||
|
|
||||||
|
var authMethods []usermodel.AuthMethods
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Model(&usermodel.AuthMethods{}).
|
||||||
|
Select("user_id, auth_identifier").
|
||||||
|
Where("auth_type = ? AND auth_identifier LIKE ?", "email", "%@"+domain).
|
||||||
|
Find(&authMethods).Error; err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, am := range authMethods {
|
||||||
|
if NormalizeEmail(am.AuthIdentifier) != normalized {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var count int64
|
||||||
|
if err := db.WithContext(ctx).
|
||||||
|
Model(&usermodel.Subscribe{}).
|
||||||
|
Where("user_id = ? AND subscribe_id = ?", am.UserId, trialSubscribeId).
|
||||||
|
Count(&count).Error; err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if count > 0 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
198
internal/logic/auth/trialEmailWhitelist_test.go
Normal file
198
internal/logic/auth/trialEmailWhitelist_test.go
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
package auth
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeEmail(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
// Gmail dot trick
|
||||||
|
{"a.v.x.xx@gmail.com", "avxxx@gmail.com"},
|
||||||
|
{"john.doe@gmail.com", "johndoe@gmail.com"},
|
||||||
|
{"a.b.c.d.e@gmail.com", "abcde@gmail.com"},
|
||||||
|
// Gmail + alias
|
||||||
|
{"user+tag@gmail.com", "user@gmail.com"},
|
||||||
|
{"a.b+tag@gmail.com", "ab@gmail.com"},
|
||||||
|
// Googlemail
|
||||||
|
{"a.b@googlemail.com", "ab@googlemail.com"},
|
||||||
|
// Non-Gmail: dots preserved
|
||||||
|
{"john.doe@outlook.com", "john.doe@outlook.com"},
|
||||||
|
{"john.doe@qq.com", "john.doe@qq.com"},
|
||||||
|
// + alias stripped for all providers
|
||||||
|
{"user+spam@outlook.com", "user@outlook.com"},
|
||||||
|
{"user+spam@qq.com", "user@qq.com"},
|
||||||
|
// Case insensitive
|
||||||
|
{"User@Gmail.COM", "user@gmail.com"},
|
||||||
|
{"A.B@Gmail.com", "ab@gmail.com"},
|
||||||
|
// No change for normal non-gmail email
|
||||||
|
{"abc@163.com", "abc@163.com"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := NormalizeEmail(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("NormalizeEmail(%q) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeEmail_NoChangeSkipsCheck(t *testing.T) {
|
||||||
|
// These emails should NOT trigger cross-user check (normalized == original)
|
||||||
|
noChangeCases := []string{
|
||||||
|
"abc@163.com",
|
||||||
|
"john.doe@outlook.com",
|
||||||
|
"user@qq.com",
|
||||||
|
}
|
||||||
|
for _, email := range noChangeCases {
|
||||||
|
normalized := NormalizeEmail(email)
|
||||||
|
lower := email
|
||||||
|
if normalized == lower {
|
||||||
|
// correct: no normalization change, NormalizedEmailHasTrial would return false early
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldGrantTrialForEmail(t *testing.T) {
|
||||||
|
// 模拟线上配置:白名单开启,gmail.com 也在名单里
|
||||||
|
rcWithGmail := config.RegisterConfig{
|
||||||
|
EnableTrial: true,
|
||||||
|
EnableTrialEmailWhitelist: true,
|
||||||
|
TrialEmailDomainWhitelist: "hifastapp.com,hifastvpn.com,126.com,139.com,163.com,gmail.com",
|
||||||
|
}
|
||||||
|
// 白名单关闭
|
||||||
|
rcNoWhitelist := config.RegisterConfig{
|
||||||
|
EnableTrial: true,
|
||||||
|
EnableTrialEmailWhitelist: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
rc config.RegisterConfig
|
||||||
|
email string
|
||||||
|
want bool
|
||||||
|
reason string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "gmail dot trick - blocked even if gmail.com in whitelist",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||||
|
want: false,
|
||||||
|
reason: "gmail 泛域名(含点号)应拒绝",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gmail plus alias - blocked",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "user+tag@gmail.com",
|
||||||
|
want: false,
|
||||||
|
reason: "gmail +别名应拒绝",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "clean gmail - allowed",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "normaluser@gmail.com",
|
||||||
|
want: true,
|
||||||
|
reason: "干净的 gmail 应放行",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "163 with dot - allowed (non-gmail dot is ok)",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "s.m.s.n@163.com",
|
||||||
|
want: true,
|
||||||
|
reason: "非 gmail 域点号不拦截",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "163 plus alias - blocked",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "user+spam@163.com",
|
||||||
|
want: false,
|
||||||
|
reason: "所有域名的 +别名都拦截",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "gmail typo squatting domain - blocked even if accidentally whitelisted",
|
||||||
|
rc: config.RegisterConfig{EnableTrial: true, EnableTrialEmailWhitelist: true, TrialEmailDomainWhitelist: "gmail.com,gmaial.com"},
|
||||||
|
email: "1.2.3.4xxx@gmaial.com",
|
||||||
|
want: false,
|
||||||
|
reason: "易混淆 Gmail 域名不应发放试用",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid empty local - blocked",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "@gmail.com",
|
||||||
|
want: false,
|
||||||
|
reason: "邮箱 local 为空应拒绝",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "subdomain spoof - blocked",
|
||||||
|
rc: rcWithGmail,
|
||||||
|
email: "user@fake.gmail.com",
|
||||||
|
want: false,
|
||||||
|
reason: "白名单必须精确匹配域名,不匹配子域",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitelist disabled - gmail dot trick still blocked",
|
||||||
|
rc: rcNoWhitelist,
|
||||||
|
email: "s.m.s.n.fsmbt.d.ndny@gmail.com",
|
||||||
|
want: false,
|
||||||
|
reason: "白名单未启用,但泛域名仍应拒绝",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "trial disabled - always blocked",
|
||||||
|
rc: config.RegisterConfig{EnableTrial: false},
|
||||||
|
email: "user@163.com",
|
||||||
|
want: false,
|
||||||
|
reason: "试用未开启",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := ShouldGrantTrialForEmail(tt.rc, tt.email)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("ShouldGrantTrialForEmail(%q) = %v, want %v | reason: %s",
|
||||||
|
tt.email, got, tt.want, tt.reason)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestShouldAutoGrantTrialOnPublicEmailFlows(t *testing.T) {
|
||||||
|
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{}))
|
||||||
|
assert.False(t, ShouldAutoGrantTrialOnPublicEmailFlows(config.RegisterConfig{
|
||||||
|
EnableTrial: true,
|
||||||
|
EnableTrialEmailWhitelist: true,
|
||||||
|
TrialEmailDomainWhitelist: "gmail.com,example.com",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsEmailDomainWhitelisted(t *testing.T) {
|
||||||
|
whitelist := "gmail.com,edu.cn,outlook.com"
|
||||||
|
tests := []struct {
|
||||||
|
email string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"user@gmail.com", true},
|
||||||
|
{"user@edu.cn", true},
|
||||||
|
{"User@Gmail.COM", true},
|
||||||
|
{"user@yahoo.com", false},
|
||||||
|
{"user@fake.gmail.com", false}, // subdomain not matched
|
||||||
|
{"user@", false},
|
||||||
|
{"notanemail", false},
|
||||||
|
{"@gmail.com", false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.email, func(t *testing.T) {
|
||||||
|
got := IsEmailDomainWhitelisted(tt.email, whitelist)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("IsEmailDomainWhitelisted(%q) = %v, want %v", tt.email, got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -148,7 +148,9 @@ func (l *UserRegisterLogic) UserRegister(req *types.UserRegisterRequest) (resp *
|
|||||||
|
|
||||||
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
|
// Activate trial subscription after transaction success (moved outside transaction to reduce lock time)
|
||||||
rc := l.svcCtx.Config.Register
|
rc := l.svcCtx.Config.Register
|
||||||
if ShouldGrantTrialForEmail(rc, req.Email) {
|
if ShouldAutoGrantTrialOnPublicEmailFlows(rc) &&
|
||||||
|
ShouldGrantTrialForEmail(rc, req.Email) &&
|
||||||
|
!NormalizedEmailHasTrial(l.ctx, l.svcCtx.DB, req.Email, rc.TrialSubscribe) {
|
||||||
trialSubscribe, err = l.activeTrial(userInfo.Id)
|
trialSubscribe, err = l.activeTrial(userInfo.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
|
l.Errorw("Failed to activate trial subscription", logger.Field("error", err.Error()))
|
||||||
|
|||||||
@ -76,7 +76,10 @@ func CountScopedSubscribePurchaseOrders(
|
|||||||
var count int64
|
var count int64
|
||||||
query := db.WithContext(ctx).
|
query := db.WithContext(ctx).
|
||||||
Model(&modelOrder.Order{}).
|
Model(&modelOrder.Order{}).
|
||||||
Where("user_id IN ? AND subscribe_id = ? AND type IN ? AND amount > 0", scopeUserIDs, subscribeID, []int64{1, 2})
|
Where("user_id IN ? AND type IN ?", scopeUserIDs, []int64{1, 2})
|
||||||
|
if subscribeID > 0 {
|
||||||
|
query = query.Where("subscribe_id = ?", subscribeID)
|
||||||
|
}
|
||||||
if len(statuses) > 0 {
|
if len(statuses) > 0 {
|
||||||
query = query.Where("status IN ?", statuses)
|
query = query.Where("status IN ?", statuses)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,12 +52,8 @@ func ResolvePurchaseRoute(
|
|||||||
return decision, nil
|
return decision, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if requestedSubscribeID != anchorSub.SubscribeId {
|
|
||||||
return nil, ErrSingleModePlanMismatch
|
|
||||||
}
|
|
||||||
|
|
||||||
decision.Route = PurchaseRoutePurchaseToRenewal
|
decision.Route = PurchaseRoutePurchaseToRenewal
|
||||||
decision.ResolvedSubscribeID = anchorSub.SubscribeId
|
decision.ResolvedSubscribeID = requestedSubscribeID
|
||||||
decision.Anchor = anchorSub
|
decision.Anchor = anchorSub
|
||||||
return decision, nil
|
return decision, nil
|
||||||
}
|
}
|
||||||
|
|||||||
36
internal/logic/common/subscribeModeRoute_test.go
Normal file
36
internal/logic/common/subscribeModeRoute_test.go
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResolvePurchaseRoute_AllowsPlanChangeForExistingSubscription(t *testing.T) {
|
||||||
|
anchor := &user.Subscribe{
|
||||||
|
Id: 10,
|
||||||
|
UserId: 20,
|
||||||
|
OrderId: 30,
|
||||||
|
SubscribeId: 1,
|
||||||
|
Token: "existing-token",
|
||||||
|
ExpireTime: time.Now().Add(time.Hour),
|
||||||
|
}
|
||||||
|
|
||||||
|
decision, err := ResolvePurchaseRoute(
|
||||||
|
context.Background(),
|
||||||
|
true,
|
||||||
|
anchor.UserId,
|
||||||
|
2,
|
||||||
|
func(context.Context, int64) (*user.Subscribe, error) {
|
||||||
|
return anchor, nil
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Equal(t, PurchaseRoutePurchaseToRenewal, decision.Route)
|
||||||
|
require.Equal(t, int64(2), decision.ResolvedSubscribeID)
|
||||||
|
require.Equal(t, anchor, decision.Anchor)
|
||||||
|
}
|
||||||
108
internal/logic/common/subscriptionTrace.go
Normal file
108
internal/logic/common/subscriptionTrace.go
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
ordermodel "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
usermodel "github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
SubscriptionTraceType = "subscription_flow"
|
||||||
|
SubscriptionTraceFlowOrder = "order_subscription"
|
||||||
|
SubscriptionTraceFlowEmailBind = "email_bind_subscription"
|
||||||
|
)
|
||||||
|
|
||||||
|
func SubscriptionTraceFields(flow string, stage string, fields ...logger.LogField) []logger.LogField {
|
||||||
|
base := []logger.LogField{
|
||||||
|
logger.Field("trace_type", SubscriptionTraceType),
|
||||||
|
logger.Field("flow", flow),
|
||||||
|
logger.Field("stage", stage),
|
||||||
|
}
|
||||||
|
|
||||||
|
return append(base, fields...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubscriptionTraceInfo(log logger.Logger, flow string, stage string, msg string, fields ...logger.LogField) {
|
||||||
|
log.Infow(msg, SubscriptionTraceFields(flow, stage, fields...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SubscriptionTraceError(log logger.Logger, flow string, stage string, msg string, fields ...logger.LogField) {
|
||||||
|
log.Errorw(msg, SubscriptionTraceFields(flow, stage, fields...)...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func OrderTraceFields(orderInfo *ordermodel.Order) []logger.LogField {
|
||||||
|
if orderInfo == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveUserID := orderInfo.UserId
|
||||||
|
if orderInfo.SubscriptionUserId > 0 {
|
||||||
|
effectiveUserID = orderInfo.SubscriptionUserId
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []logger.LogField{
|
||||||
|
logger.Field("order_id", orderInfo.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("order_type", orderInfo.Type),
|
||||||
|
logger.Field("order_status", orderInfo.Status),
|
||||||
|
logger.Field("user_id", orderInfo.UserId),
|
||||||
|
logger.Field("subscription_user_id", orderInfo.SubscriptionUserId),
|
||||||
|
logger.Field("effective_user_id", effectiveUserID),
|
||||||
|
logger.Field("order_subscribe_id", orderInfo.SubscribeId),
|
||||||
|
logger.Field("payment_id", orderInfo.PaymentId),
|
||||||
|
logger.Field("payment_method", orderInfo.Method),
|
||||||
|
logger.Field("parent_order_id", orderInfo.ParentId),
|
||||||
|
logger.Field("quantity", orderInfo.Quantity),
|
||||||
|
logger.Field("is_new_order", orderInfo.IsNew),
|
||||||
|
}
|
||||||
|
|
||||||
|
if tail := SensitiveTail(orderInfo.SubscribeToken); tail != "" {
|
||||||
|
fields = append(fields, logger.Field("subscribe_token_tail", tail))
|
||||||
|
}
|
||||||
|
if tail := SensitiveTail(orderInfo.TradeNo); tail != "" {
|
||||||
|
fields = append(fields, logger.Field("trade_no_tail", tail))
|
||||||
|
}
|
||||||
|
if tail := SensitiveTail(orderInfo.AppAccountToken); tail != "" {
|
||||||
|
fields = append(fields, logger.Field("app_account_token_tail", tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func UserSubscribeTraceFields(userSub *usermodel.Subscribe) []logger.LogField {
|
||||||
|
if userSub == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
fields := []logger.LogField{
|
||||||
|
logger.Field("user_subscribe_id", userSub.Id),
|
||||||
|
logger.Field("subscribe_owner_user_id", userSub.UserId),
|
||||||
|
logger.Field("user_subscribe_plan_id", userSub.SubscribeId),
|
||||||
|
logger.Field("subscribe_order_id", userSub.OrderId),
|
||||||
|
logger.Field("subscribe_status", userSub.Status),
|
||||||
|
logger.Field("expire_time", userSub.ExpireTime),
|
||||||
|
}
|
||||||
|
|
||||||
|
if tail := SensitiveTail(userSub.Token); tail != "" {
|
||||||
|
fields = append(fields, logger.Field("subscribe_token_tail", tail))
|
||||||
|
}
|
||||||
|
if tail := SensitiveTail(userSub.UUID); tail != "" {
|
||||||
|
fields = append(fields, logger.Field("subscribe_uuid_tail", tail))
|
||||||
|
}
|
||||||
|
|
||||||
|
return fields
|
||||||
|
}
|
||||||
|
|
||||||
|
func SensitiveTail(value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if len(value) <= 8 {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
return value[len(value)-8:]
|
||||||
|
}
|
||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
@ -56,6 +57,12 @@ func (l *AlipayNotifyLogic) AlipayNotify(r *http.Request) error {
|
|||||||
l.Logger.Error("[AlipayNotify] Decode notification failed", logger.Field("error", err.Error()))
|
l.Logger.Error("[AlipayNotify] Decode notification failed", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_notify_received",
|
||||||
|
"[SubscriptionFlow] alipay notify received",
|
||||||
|
logger.Field("order_no", notify.OrderNo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
logger.Field("notify_status", string(notify.Status)),
|
||||||
|
)
|
||||||
if notify.Status == alipay.Success {
|
if notify.Status == alipay.Success {
|
||||||
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, notify.OrderNo)
|
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, notify.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -73,6 +80,12 @@ func (l *AlipayNotifyLogic) AlipayNotify(r *http.Request) error {
|
|||||||
l.Logger.Error("[AlipayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", notify.OrderNo))
|
l.Logger.Error("[AlipayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", notify.OrderNo))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] alipay notify marked order as paid",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
l.Logger.Info("[AlipayNotify] Notify status success", logger.Field("orderNo", notify.OrderNo))
|
l.Logger.Info("[AlipayNotify] Notify status success", logger.Field("orderNo", notify.OrderNo))
|
||||||
payload := types.ForthwithActivateOrderPayload{
|
payload := types.ForthwithActivateOrderPayload{
|
||||||
OrderNo: notify.OrderNo,
|
OrderNo: notify.OrderNo,
|
||||||
@ -88,6 +101,13 @@ func (l *AlipayNotifyLogic) AlipayNotify(r *http.Request) error {
|
|||||||
l.Logger.Error("[AlipayNotify] Enqueue task failed", logger.Field("error", err.Error()))
|
l.Logger.Error("[AlipayNotify] Enqueue task failed", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
||||||
|
"[SubscriptionFlow] activation task enqueued from alipay notify",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
logger.Field("queue_task_id", taskInfo.ID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
l.Logger.Info("[AlipayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
|
l.Logger.Info("[AlipayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
|
||||||
} else {
|
} else {
|
||||||
l.Logger.Error("[AlipayNotify] Notify status failed", logger.Field("status", string(notify.Status)))
|
l.Logger.Error("[AlipayNotify] Notify status failed", logger.Field("status", string(notify.Status)))
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
iapmodel "github.com/perfect-panel/server/internal/model/iap/apple"
|
||||||
"github.com/perfect-panel/server/internal/model/subscribe"
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
@ -57,6 +58,13 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
|||||||
}
|
}
|
||||||
// 验签通过,记录通知类型与关键交易标识
|
// 验签通过,记录通知类型与关键交易标识
|
||||||
l.Infow("iap notify verified", logger.Field("type", ntype), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
l.Infow("iap notify verified", logger.Field("type", ntype), logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_notify_received",
|
||||||
|
"[SubscriptionFlow] apple iap server notification received",
|
||||||
|
logger.Field("notify_type", ntype),
|
||||||
|
logger.Field("product_id", txPayload.ProductId),
|
||||||
|
logger.Field("original_transaction_tail", commonLogic.SensitiveTail(txPayload.OriginalTransactionId)),
|
||||||
|
logger.Field("transaction_id_tail", commonLogic.SensitiveTail(txPayload.TransactionId)),
|
||||||
|
)
|
||||||
return l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
return l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
||||||
var existing *iapmodel.Transaction
|
var existing *iapmodel.Transaction
|
||||||
existing, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
|
existing, _ = iapmodel.NewModel(l.svcCtx.DB, l.svcCtx.Redis).FindByOriginalId(l.ctx, txPayload.OriginalTransactionId)
|
||||||
@ -201,6 +209,13 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
l.Infow("iap notify fallback updated subscribe", logger.Field("userSubscribeId", candidate.Id), logger.Field("status", candidate.Status))
|
l.Infow("iap notify fallback updated subscribe", logger.Field("userSubscribeId", candidate.Id), logger.Field("status", candidate.Status))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "subscription_updated_from_notify",
|
||||||
|
"[SubscriptionFlow] apple iap notify updated fallback subscription candidate",
|
||||||
|
append(commonLogic.UserSubscribeTraceFields(candidate),
|
||||||
|
logger.Field("notify_type", ntype),
|
||||||
|
logger.Field("product_id", txPayload.ProductId),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -226,6 +241,13 @@ func (l *AppleIAPNotifyLogic) Handle(signedPayload string) error {
|
|||||||
}
|
}
|
||||||
// 更新成功,输出订阅状态
|
// 更新成功,输出订阅状态
|
||||||
l.Infow("iap notify updated subscribe", logger.Field("userSubscribeId", sub.Id), logger.Field("status", sub.Status))
|
l.Infow("iap notify updated subscribe", logger.Field("userSubscribeId", sub.Id), logger.Field("status", sub.Status))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "subscription_updated_from_notify",
|
||||||
|
"[SubscriptionFlow] apple iap notify updated subscription",
|
||||||
|
append(commonLogic.UserSubscribeTraceFields(sub),
|
||||||
|
logger.Field("notify_type", ntype),
|
||||||
|
logger.Field("product_id", txPayload.ProductId),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
@ -44,12 +45,18 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
|||||||
l.Logger.Error("[EPayNotify] Payment not found in context")
|
l.Logger.Error("[EPayNotify] Payment not found in context")
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "payment config not found")
|
return errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "payment config not found")
|
||||||
}
|
}
|
||||||
l.Infof("[EPayNotify] Payment config: %+v", data)
|
|
||||||
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
|
orderInfo, err := l.svcCtx.OrderModel.FindOneByOrderNo(l.ctx, req.OutTradeNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
l.Logger.Error("[EPayNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", req.OutTradeNo)
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_notify_received",
|
||||||
|
"[SubscriptionFlow] epay notify received",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
logger.Field("trade_status", req.TradeStatus),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
var config payment.EPayConfig
|
var config payment.EPayConfig
|
||||||
if err := json.Unmarshal([]byte(data.Config), &config); err != nil {
|
if err := json.Unmarshal([]byte(data.Config), &config); err != nil {
|
||||||
@ -75,6 +82,12 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
|||||||
l.Logger.Error("[EPayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
l.Logger.Error("[EPayNotify] Update order status failed", logger.Field("error", err.Error()), logger.Field("orderNo", req.OutTradeNo))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] epay notify marked order as paid",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
// Create activate order task
|
// Create activate order task
|
||||||
payload := queueType.ForthwithActivateOrderPayload{
|
payload := queueType.ForthwithActivateOrderPayload{
|
||||||
OrderNo: req.OutTradeNo,
|
OrderNo: req.OutTradeNo,
|
||||||
@ -90,6 +103,13 @@ func (l *EPayNotifyLogic) EPayNotify(req *types.EPayNotifyRequest) error {
|
|||||||
l.Logger.Error("[EPayNotify] Enqueue task failed", logger.Field("error", err.Error()))
|
l.Logger.Error("[EPayNotify] Enqueue task failed", logger.Field("error", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
||||||
|
"[SubscriptionFlow] activation task enqueued from epay notify",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", data.Platform),
|
||||||
|
logger.Field("queue_task_id", taskInfo.ID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
l.Logger.Info("[EPayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
|
l.Logger.Info("[EPayNotify] Enqueue task success", logger.Field("taskInfo", taskInfo))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/pkg/xerr"
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
@ -67,6 +68,13 @@ func (l *StripeNotifyLogic) StripeNotify(r *http.Request, w http.ResponseWriter)
|
|||||||
l.Logger.Error("[StripeNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", notify.OrderNo))
|
l.Logger.Error("[StripeNotify] Find order failed", logger.Field("error", err.Error()), logger.Field("orderNo", notify.OrderNo))
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", notify.OrderNo)
|
return errors.Wrapf(xerr.NewErrCode(xerr.OrderNotExist), "order not exist: %v", notify.OrderNo)
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_notify_received",
|
||||||
|
"[SubscriptionFlow] stripe notify received",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", stripeConfig.Platform),
|
||||||
|
logger.Field("stripe_event_type", notify.EventType),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
if notify.EventType == "payment_intent.succeeded" {
|
if notify.EventType == "payment_intent.succeeded" {
|
||||||
if orderInfo.Status == 5 {
|
if orderInfo.Status == 5 {
|
||||||
return nil
|
return nil
|
||||||
@ -76,6 +84,13 @@ func (l *StripeNotifyLogic) StripeNotify(r *http.Request, w http.ResponseWriter)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] stripe notify marked order as paid",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", stripeConfig.Platform),
|
||||||
|
logger.Field("stripe_event_type", notify.EventType),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
// create ActivateOrder task
|
// create ActivateOrder task
|
||||||
payload := types.ForthwithActivateOrderPayload{
|
payload := types.ForthwithActivateOrderPayload{
|
||||||
OrderNo: notify.OrderNo,
|
OrderNo: notify.OrderNo,
|
||||||
@ -86,11 +101,19 @@ func (l *StripeNotifyLogic) StripeNotify(r *http.Request, w http.ResponseWriter)
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
task := asynq.NewTask(types.ForthwithActivateOrder, bytes, asynq.MaxRetry(5))
|
task := asynq.NewTask(types.ForthwithActivateOrder, bytes, asynq.MaxRetry(5))
|
||||||
_, err = l.svcCtx.Queue.Enqueue(task)
|
taskInfo, err := l.svcCtx.Queue.Enqueue(task)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
l.Errorw("[StripeNotify] Enqueue error", logger.Field("errors", err.Error()))
|
l.Errorw("[StripeNotify] Enqueue error", logger.Field("errors", err.Error()))
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
||||||
|
"[SubscriptionFlow] activation task enqueued from stripe notify",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", stripeConfig.Platform),
|
||||||
|
logger.Field("stripe_event_type", notify.EventType),
|
||||||
|
logger.Field("queue_task_id", taskInfo.ID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
l.Infow("[StripeNotify] success", logger.Field("orderNo", notify.OrderNo))
|
l.Infow("[StripeNotify] success", logger.Field("orderNo", notify.OrderNo))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -82,6 +82,13 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
|||||||
l.Errorw("订单与当前用户不匹配", logger.Field("orderNo", req.OrderNo), logger.Field("orderUserId", orderInfo.UserId), logger.Field("userId", u.Id))
|
l.Errorw("订单与当前用户不匹配", logger.Field("orderNo", req.OrderNo), logger.Field("orderUserId", orderInfo.UserId), logger.Field("userId", u.Id))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order owner mismatch")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "order owner mismatch")
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "iap_attach_start",
|
||||||
|
"[SubscriptionFlow] apple iap attach flow started",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("request_user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
isNewPurchaseOrder := orderInfo.Type == orderTypeSubscribe
|
isNewPurchaseOrder := orderInfo.Type == orderTypeSubscribe
|
||||||
if isNewPurchaseOrder {
|
if isNewPurchaseOrder {
|
||||||
l.Infow("首购订单将只由订单激活流程创建订阅", logger.Field("orderNo", req.OrderNo), logger.Field("orderType", orderInfo.Type))
|
l.Infow("首购订单将只由订单激活流程创建订阅", logger.Field("orderNo", req.OrderNo), logger.Field("orderType", orderInfo.Type))
|
||||||
@ -93,6 +100,14 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "invalid jws")
|
||||||
}
|
}
|
||||||
l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate))
|
l.Infow("JWS 验签成功", logger.Field("productId", txPayload.ProductId), logger.Field("originalTransactionId", txPayload.OriginalTransactionId), logger.Field("purchaseAt", txPayload.PurchaseDate))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "iap_attach_verified",
|
||||||
|
"[SubscriptionFlow] apple iap transaction verified",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("product_id", txPayload.ProductId),
|
||||||
|
logger.Field("original_transaction_tail", commonLogic.SensitiveTail(txPayload.OriginalTransactionId)),
|
||||||
|
logger.Field("transaction_id_tail", commonLogic.SensitiveTail(txPayload.TransactionId)),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
tradeNoCandidates := l.getAppleTradeNoCandidates(txPayload)
|
tradeNoCandidates := l.getAppleTradeNoCandidates(txPayload)
|
||||||
existingOrderNo, validateErr := l.validateOrderTradeNoBinding(orderInfo, tradeNoCandidates)
|
existingOrderNo, validateErr := l.validateOrderTradeNoBinding(orderInfo, tradeNoCandidates)
|
||||||
if validateErr != nil {
|
if validateErr != nil {
|
||||||
@ -390,6 +405,12 @@ func (l *AttachTransactionLogic) Attach(req *types.AttachAppleTransactionRequest
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
|
l.Infow("写入用户订阅成功", logger.Field("userId", u.Id), logger.Field("subscribeId", subscribeId), logger.Field("expireUnix", exp.Unix()))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "subscription_created",
|
||||||
|
"[SubscriptionFlow] apple iap attach created a subscription placeholder before queue activation",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
commonLogic.UserSubscribeTraceFields(&userSub)...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
l.Infow("首购订单跳过 attach 阶段订阅写入", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderType", orderInfo.Type))
|
l.Infow("首购订单跳过 attach 阶段订阅写入", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("orderType", orderInfo.Type))
|
||||||
@ -453,6 +474,12 @@ func (l *AttachTransactionLogic) syncOrderStatusAndEnqueue(orderInfo *ordermodel
|
|||||||
}
|
}
|
||||||
orderInfo.Status = orderStatusPaid
|
orderInfo.Status = orderStatusPaid
|
||||||
l.Infow("更新订单状态成功", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("status", orderStatusPaid))
|
l.Infow("更新订单状态成功", logger.Field("orderNo", orderInfo.OrderNo), logger.Field("status", orderStatusPaid))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] apple iap attach marked order as paid",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("iap_expire_at", iapExpireAt),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
// enqueue activation regardless (idempotent handler downstream)
|
// enqueue activation regardless (idempotent handler downstream)
|
||||||
payload := queueType.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo, IAPExpireAt: iapExpireAt}
|
payload := queueType.ForthwithActivateOrderPayload{OrderNo: orderInfo.OrderNo, IAPExpireAt: iapExpireAt}
|
||||||
@ -463,6 +490,12 @@ func (l *AttachTransactionLogic) syncOrderStatusAndEnqueue(orderInfo *ordermodel
|
|||||||
l.Errorw("enqueue activate task error", logger.Field("error", err.Error()))
|
l.Errorw("enqueue activate task error", logger.Field("error", err.Error()))
|
||||||
} else {
|
} else {
|
||||||
l.Infow("已加入订单激活队列", logger.Field("orderNo", orderInfo.OrderNo))
|
l.Infow("已加入订单激活队列", logger.Field("orderNo", orderInfo.OrderNo))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
||||||
|
"[SubscriptionFlow] apple iap attach enqueued activation task",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("iap_expire_at", iapExpireAt),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,6 +12,9 @@ func getDiscount(discounts []types.SubscribeDiscount, inputMonths int64, isNewUs
|
|||||||
if d.Quantity != inputMonths || d.Discount <= 0 || d.Discount >= 100 {
|
if d.Quantity != inputMonths || d.Discount <= 0 || d.Discount >= 100 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
if d.NewUserOnly && !isNewUser {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if isNewUser {
|
if isNewUser {
|
||||||
// lowest discount value = biggest saving
|
// lowest discount value = biggest saving
|
||||||
if best < 0 || d.Discount < best {
|
if best < 0 || d.Discount < best {
|
||||||
@ -50,4 +53,3 @@ func isNewUserOnlyForQuantity(discounts []types.SubscribeDiscount, inputQuantity
|
|||||||
}
|
}
|
||||||
return hasNewUserOnly && !hasFallback
|
return hasNewUserOnly && !hasFallback
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
internal/logic/public/order/getDiscount_test.go
Normal file
16
internal/logic/public/order/getDiscount_test.go
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetDiscount_SkipsNewUserOnlyTierForExistingUser(t *testing.T) {
|
||||||
|
discount := getDiscount([]types.SubscribeDiscount{
|
||||||
|
{Quantity: 1, Discount: 90, NewUserOnly: true},
|
||||||
|
}, 1, false)
|
||||||
|
|
||||||
|
require.Equal(t, float64(1), discount)
|
||||||
|
}
|
||||||
@ -47,7 +47,7 @@ func resolveNewUserDiscountEligibility(
|
|||||||
ctx,
|
ctx,
|
||||||
db,
|
db,
|
||||||
eligibility.ScopeUserIDs,
|
eligibility.ScopeUserIDs,
|
||||||
subscribeID,
|
0,
|
||||||
[]int64{2, 5},
|
[]int64{2, 5},
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math"
|
"math"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
@ -62,6 +63,17 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
return nil, entErr
|
return nil, entErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_create_start",
|
||||||
|
"[SubscriptionFlow] purchase order creation started",
|
||||||
|
logger.Field("order_kind", "purchase"),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
logger.Field("requested_subscribe_id", req.SubscribeId),
|
||||||
|
logger.Field("quantity", req.Quantity),
|
||||||
|
logger.Field("payment_id", req.Payment),
|
||||||
|
logger.Field("coupon", req.Coupon),
|
||||||
|
)
|
||||||
|
|
||||||
if req.Quantity <= 0 {
|
if req.Quantity <= 0 {
|
||||||
l.Debugf("[Purchase] Quantity is less than or equal to 0, setting to 1")
|
l.Debugf("[Purchase] Quantity is less than or equal to 0, setting to 1")
|
||||||
req.Quantity = 1
|
req.Quantity = 1
|
||||||
@ -101,12 +113,42 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
parentOrderID = decision.Anchor.OrderId
|
parentOrderID = decision.Anchor.OrderId
|
||||||
subscribeToken = decision.Anchor.Token
|
subscribeToken = decision.Anchor.Token
|
||||||
anchorUserSubscribeID = decision.Anchor.Id
|
anchorUserSubscribeID = decision.Anchor.Id
|
||||||
l.Infow("[Purchase] single mode purchase routed to renewal",
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_route_selected",
|
||||||
logger.Field("mode", "single"),
|
"[SubscriptionFlow] purchase routed to renewal before order creation",
|
||||||
|
logger.Field("route_mode", "single"),
|
||||||
logger.Field("route", "purchase_to_renewal"),
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
|
logger.Field("anchor_user_subscribe_id", decision.Anchor.Id),
|
||||||
logger.Field("order_no", "pending"),
|
|
||||||
logger.Field("user_id", u.Id),
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
logger.Field("requested_subscribe_id", req.SubscribeId),
|
||||||
|
logger.Field("resolved_subscribe_id", targetSubscribeID),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 全局单订阅口径:若用户已有任意付费订阅(含过期),提前路由为续费/换套餐,
|
||||||
|
// 防止不同套餐购买创建第二条订阅。
|
||||||
|
if !l.svcCtx.Config.Subscribe.SingleModel && orderType == 1 {
|
||||||
|
var existSub user.Subscribe
|
||||||
|
if e := l.svcCtx.DB.WithContext(l.ctx).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id = ? AND token != '' AND (order_id > 0 OR token LIKE 'iap:%')", entitlement.EffectiveUserID).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&existSub).Error; e == nil && existSub.Id > 0 && existSub.Token != "" {
|
||||||
|
orderType = 2
|
||||||
|
parentOrderID = existSub.OrderId
|
||||||
|
subscribeToken = existSub.Token
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_route_selected",
|
||||||
|
"[SubscriptionFlow] purchase routed to renewal because an existing subscription was found",
|
||||||
|
logger.Field("route_mode", "global_single_subscription"),
|
||||||
|
logger.Field("route", "purchase_to_existing_subscription"),
|
||||||
|
logger.Field("existing_subscribe_id", existSub.Id),
|
||||||
|
logger.Field("existing_status", existSub.Status),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
logger.Field("resolved_subscribe_id", targetSubscribeID),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -277,13 +319,13 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
AppAccountToken: uuid.New().String(),
|
AppAccountToken: uuid.New().String(),
|
||||||
}
|
}
|
||||||
if isSingleModeRenewal {
|
if isSingleModeRenewal {
|
||||||
l.Infow("[Purchase] single mode purchase order created as renewal",
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_created",
|
||||||
logger.Field("mode", "single"),
|
"[SubscriptionFlow] purchase order persisted as renewal",
|
||||||
logger.Field("route", "purchase_to_renewal"),
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
logger.Field("anchor_user_subscribe_id", anchorUserSubscribeID),
|
logger.Field("route_mode", "single"),
|
||||||
logger.Field("order_no", orderInfo.OrderNo),
|
logger.Field("route", "purchase_to_renewal"),
|
||||||
logger.Field("parent_id", orderInfo.ParentId),
|
logger.Field("anchor_user_subscribe_id", anchorUserSubscribeID),
|
||||||
logger.Field("user_id", u.Id),
|
)...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// Database transaction
|
// Database transaction
|
||||||
@ -291,13 +333,13 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
// check subscribe plan quota limit inside transaction to prevent race condition
|
// check subscribe plan quota limit inside transaction to prevent race condition
|
||||||
if orderInfo.Type == 1 && sub.Quota > 0 {
|
if orderInfo.Type == 1 && sub.Quota > 0 {
|
||||||
var currentUserSub []user.Subscribe
|
var currentUserSub []user.Subscribe
|
||||||
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", u.Id).Find(¤tUserSub).Error; e != nil {
|
if e := db.Model(&user.Subscribe{}).Where("user_id = ?", entitlement.EffectiveUserID).Find(¤tUserSub).Error; e != nil {
|
||||||
l.Errorw("[Purchase] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
l.Errorw("[Purchase] Database query error", logger.Field("error", e.Error()), logger.Field("user_id", u.Id))
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
for _, v := range currentUserSub {
|
for _, v := range currentUserSub {
|
||||||
if v.SubscribeId == targetSubscribeID {
|
if v.OrderId > 0 || strings.HasPrefix(v.Token, "iap:") {
|
||||||
count++
|
count++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -380,6 +422,16 @@ func (l *PurchaseLogic) Purchase(req *types.PurchaseOrderRequest) (resp *types.P
|
|||||||
}
|
}
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseInsertError), "insert order error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !isSingleModeRenewal {
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_created",
|
||||||
|
"[SubscriptionFlow] purchase order persisted",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("route_mode", "standard"),
|
||||||
|
logger.Field("resolved_subscribe_id", targetSubscribeID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
}
|
||||||
// Deferred task
|
// Deferred task
|
||||||
payload := queue.DeferCloseOrderPayload{
|
payload := queue.DeferCloseOrderPayload{
|
||||||
OrderNo: orderInfo.OrderNo,
|
OrderNo: orderInfo.OrderNo,
|
||||||
|
|||||||
766
internal/logic/public/order/purchaseNewUserOnly_test.go
Normal file
766
internal/logic/public/order/purchaseNewUserOnly_test.go
Normal file
@ -0,0 +1,766 @@
|
|||||||
|
package order
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/alicebob/miniredis/v2"
|
||||||
|
"github.com/hibiken/asynq"
|
||||||
|
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/payment"
|
||||||
|
subModel "github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/perfect-panel/server/pkg/xerr"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupNewUserOnlyDB 创建带必要表的 SQLite 内存数据库
|
||||||
|
func setupNewUserOnlyDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
require.NoError(t, err, "failed to open in-memory SQLite")
|
||||||
|
db.Exec("PRAGMA foreign_keys = OFF")
|
||||||
|
|
||||||
|
sqls := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS "subscribe" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
language VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
description TEXT,
|
||||||
|
unit_price INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unit_time VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
discount TEXT,
|
||||||
|
replacement INTEGER NOT NULL DEFAULT 0,
|
||||||
|
inventory INTEGER NOT NULL DEFAULT -1,
|
||||||
|
traffic INTEGER NOT NULL DEFAULT 0,
|
||||||
|
speed_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
device_limit INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quota INTEGER NOT NULL DEFAULT 0,
|
||||||
|
new_user_only TINYINT DEFAULT 0,
|
||||||
|
nodes VARCHAR(255),
|
||||||
|
node_tags VARCHAR(255),
|
||||||
|
show TINYINT NOT NULL DEFAULT 0,
|
||||||
|
sell TINYINT NOT NULL DEFAULT 1,
|
||||||
|
sort INTEGER NOT NULL DEFAULT 0,
|
||||||
|
deduction_ratio INTEGER DEFAULT 0,
|
||||||
|
allow_deduction TINYINT DEFAULT 1,
|
||||||
|
reset_cycle INTEGER DEFAULT 0,
|
||||||
|
renewal_reset TINYINT DEFAULT 0,
|
||||||
|
show_original_price TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "order" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
parent_id INTEGER DEFAULT NULL,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subscription_user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||||
|
type TINYINT NOT NULL DEFAULT 1,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
price INTEGER NOT NULL DEFAULT 0,
|
||||||
|
amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
gift_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
discount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
coupon VARCHAR(255) DEFAULT NULL,
|
||||||
|
coupon_discount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
commission INTEGER NOT NULL DEFAULT 0,
|
||||||
|
payment_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
method VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
fee_amount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
trade_no VARCHAR(255) DEFAULT NULL,
|
||||||
|
app_account_token VARCHAR(255) DEFAULT NULL,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
subscribe_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subscribe_token VARCHAR(255) DEFAULT NULL,
|
||||||
|
is_new TINYINT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
password VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
algo VARCHAR(20) DEFAULT 'default',
|
||||||
|
salt VARCHAR(20) DEFAULT NULL,
|
||||||
|
avatar TEXT,
|
||||||
|
balance INTEGER DEFAULT 0,
|
||||||
|
refer_code VARCHAR(20) DEFAULT '',
|
||||||
|
referer_id INTEGER DEFAULT 0,
|
||||||
|
commission INTEGER DEFAULT 0,
|
||||||
|
referral_percentage INTEGER DEFAULT 0,
|
||||||
|
only_first_purchase TINYINT DEFAULT 1,
|
||||||
|
gift_amount INTEGER DEFAULT 0,
|
||||||
|
enable TINYINT DEFAULT 1,
|
||||||
|
is_admin TINYINT DEFAULT 0,
|
||||||
|
enable_balance_notify TINYINT DEFAULT 0,
|
||||||
|
enable_login_notify TINYINT DEFAULT 0,
|
||||||
|
enable_subscribe_notify TINYINT DEFAULT 0,
|
||||||
|
enable_trade_notify TINYINT DEFAULT 0,
|
||||||
|
rules TEXT,
|
||||||
|
member_status VARCHAR(20) DEFAULT '',
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "payment" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
platform VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
icon VARCHAR(255) DEFAULT '',
|
||||||
|
domain VARCHAR(255) DEFAULT '',
|
||||||
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
description TEXT,
|
||||||
|
fee_mode TINYINT NOT NULL DEFAULT 0,
|
||||||
|
fee_percent INTEGER DEFAULT 0,
|
||||||
|
fee_amount INTEGER DEFAULT 0,
|
||||||
|
enable TINYINT NOT NULL DEFAULT 1,
|
||||||
|
token VARCHAR(255) NOT NULL DEFAULT '' UNIQUE
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_subscribe" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
subscribe_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
expire_time DATETIME DEFAULT NULL,
|
||||||
|
finished_at DATETIME DEFAULT NULL,
|
||||||
|
traffic INTEGER DEFAULT 0,
|
||||||
|
download INTEGER DEFAULT 0,
|
||||||
|
upload INTEGER DEFAULT 0,
|
||||||
|
token VARCHAR(255) DEFAULT '' UNIQUE,
|
||||||
|
uuid VARCHAR(255) DEFAULT '' UNIQUE,
|
||||||
|
status TINYINT DEFAULT 0,
|
||||||
|
note VARCHAR(500) DEFAULT '',
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_device" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ip VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
user_agent TEXT,
|
||||||
|
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||||
|
short_code VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
online TINYINT NOT NULL DEFAULT 0,
|
||||||
|
enabled TINYINT NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_family" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
max_members INTEGER NOT NULL DEFAULT 2,
|
||||||
|
status TINYINT DEFAULT 0,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_family_member" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
family_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
role TINYINT DEFAULT 0,
|
||||||
|
status TINYINT DEFAULT 0,
|
||||||
|
join_source VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
joined_at DATETIME,
|
||||||
|
left_at DATETIME DEFAULT NULL,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, sql := range sqls {
|
||||||
|
require.NoError(t, db.Exec(sql).Error)
|
||||||
|
}
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupNewUserOnlyRedis 启动 miniredis,返回 redis.Client 和 miniredis 句柄
|
||||||
|
func setupNewUserOnlyRedis(t *testing.T) (*redis.Client, *miniredis.Miniredis) {
|
||||||
|
t.Helper()
|
||||||
|
mr, err := miniredis.Run()
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(mr.Close)
|
||||||
|
rds := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||||
|
return rds, mr
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildNewUserOnlySvcCtx 组装最小 ServiceContext(含 asynq Queue 使用 miniredis)
|
||||||
|
func buildNewUserOnlySvcCtx(db *gorm.DB, rds *redis.Client, mr *miniredis.Miniredis) *svc.ServiceContext {
|
||||||
|
queue := asynq.NewClient(asynq.RedisClientOpt{Addr: mr.Addr()})
|
||||||
|
return &svc.ServiceContext{
|
||||||
|
DB: db,
|
||||||
|
Redis: rds,
|
||||||
|
UserModel: user.NewModel(db, rds),
|
||||||
|
OrderModel: modelOrder.NewModel(db, rds),
|
||||||
|
SubscribeModel: subModel.NewModel(db, rds),
|
||||||
|
PaymentModel: payment.NewModel(db, rds),
|
||||||
|
Queue: queue,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTestSubscribe 直接用 SQL 插入 subscribe 行(绕过 GORM hook 的 MySQL 方言)
|
||||||
|
// new_user_only=true 时同时写入 discount JSON,使代码里的 discount 检查生效
|
||||||
|
func insertTestSubscribe(t *testing.T, db *gorm.DB, id int64, newUserOnly bool) {
|
||||||
|
t.Helper()
|
||||||
|
nuOnly := 0
|
||||||
|
discount := ""
|
||||||
|
if newUserOnly {
|
||||||
|
nuOnly = 1
|
||||||
|
// discount JSON 包含一个 new_user_only=true 的 tier,匹配 quantity=1
|
||||||
|
discount = `[{"quantity":1,"discount":90,"new_user_only":true}]`
|
||||||
|
}
|
||||||
|
err := db.Exec(`INSERT INTO "subscribe"
|
||||||
|
(id, name, unit_price, inventory, sell, sort, new_user_only, discount, created_at, updated_at)
|
||||||
|
VALUES (?, 'Test Plan', 1000, -1, 1, ?, ?, ?, datetime('now'), datetime('now'))`,
|
||||||
|
id, id, nuOnly, discount).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTestPayment 插入支付方式行
|
||||||
|
func insertTestPayment(t *testing.T, db *gorm.DB, id int64) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "payment"
|
||||||
|
(id, name, platform, config, enable, fee_mode, token)
|
||||||
|
VALUES (?, 'Balance', 'balance', '{}', 1, 0, ?)`,
|
||||||
|
id, "test-token").Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTestUser 插入用户行,createdAt 可控
|
||||||
|
func insertTestUser(t *testing.T, db *gorm.DB, id int64, createdAt time.Time) *user.User {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "user"
|
||||||
|
(id, password, balance, gift_amount, enable, created_at, updated_at)
|
||||||
|
VALUES (?, '', 0, 0, 1, ?, datetime('now'))`,
|
||||||
|
id, createdAt.UTC().Format("2006-01-02 15:04:05")).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
return &user.User{
|
||||||
|
Id: id,
|
||||||
|
GiftAmount: 0,
|
||||||
|
CreatedAt: createdAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "user_device"
|
||||||
|
(user_id, ip, user_agent, identifier, short_code, online, enabled, created_at, updated_at)
|
||||||
|
VALUES (?, '127.0.0.1', 'test-agent', ?, '', 0, 1, ?, datetime('now'))`,
|
||||||
|
userID,
|
||||||
|
identifier,
|
||||||
|
createdAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "user_family"
|
||||||
|
(id, owner_user_id, max_members, status, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 3, 1, datetime('now'), datetime('now'))`,
|
||||||
|
familyID,
|
||||||
|
ownerUserID,
|
||||||
|
).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertTestFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "user_family_member"
|
||||||
|
(family_id, user_id, role, status, join_source, joined_at, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, datetime('now'), datetime('now'), datetime('now'))`,
|
||||||
|
familyID,
|
||||||
|
userID,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
joinSource,
|
||||||
|
).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertTestOrder 插入一条历史订单(status=2 表示已支付)
|
||||||
|
func insertTestOrder(t *testing.T, db *gorm.DB, userID, subscribeID int64, status uint8) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "order"
|
||||||
|
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
|
||||||
|
userID, "existing-order-no", status, subscribeID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertScopedTestOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
|
||||||
|
t.Helper()
|
||||||
|
err := db.Exec(`INSERT INTO "order"
|
||||||
|
(user_id, order_no, type, status, subscribe_id, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, datetime('now'), datetime('now'))`,
|
||||||
|
userID, orderNo, status, subscribeID).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildPurchaseCtx 把 user 放入 context(模拟中间件行为)
|
||||||
|
func buildPurchaseCtx(u *user.User) context.Context {
|
||||||
|
return context.WithValue(context.Background(), constant.CtxKeyUser, u)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_UserTooOld 验证:new_user_only=true,用户注册超过 24h → 返回 SubscribeNewUserOnly
|
||||||
|
func TestPurchase_NewUserOnly_UserTooOld(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(1)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true) // new_user_only = true
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 用户注册 48 小时前 → 超出 24h 限制
|
||||||
|
u := insertTestUser(t, db, 100, time.Now().Add(-48*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
_, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
var errCode *xerr.CodeError
|
||||||
|
require.ErrorAs(t, err, &errCode)
|
||||||
|
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode(),
|
||||||
|
"注册超过24h应返回 SubscribeNewUserOnly 错误码")
|
||||||
|
|
||||||
|
// 验证订单未被创建
|
||||||
|
var count int64
|
||||||
|
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
|
||||||
|
assert.Equal(t, int64(0), count, "用户注册超时,订单不应被创建")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_AlreadyPurchased 验证:new_user_only=true,用户是新用户但已购买过
|
||||||
|
// → 允许下单(不拦截),但不享受新人折扣
|
||||||
|
func TestPurchase_NewUserOnly_AlreadyPurchased(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(2)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 用户刚注册(2h前)→ 满足时间条件
|
||||||
|
u := insertTestUser(t, db, 200, time.Now().Add(-2*time.Hour))
|
||||||
|
|
||||||
|
// 但已有一条 status=2 的历史订单(已支付)
|
||||||
|
insertTestOrder(t, db, u.Id, subID, 2)
|
||||||
|
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 不应被拦截,允许下单
|
||||||
|
require.NoError(t, err, "24h内已购用户应允许继续下单,不应返回错误")
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.NotEmpty(t, resp.OrderNo)
|
||||||
|
|
||||||
|
// 历史订单 +1(新增了一条)
|
||||||
|
var count int64
|
||||||
|
db.Model(&modelOrder.Order{}).Where("user_id = ?", u.Id).Count(&count)
|
||||||
|
assert.Equal(t, int64(2), count, "应新增一条订单")
|
||||||
|
|
||||||
|
// 新订单无折扣:Amount=Price=1000
|
||||||
|
var newOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
|
||||||
|
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
|
||||||
|
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_Success 验证:new_user_only=true,新用户首次购买 → 成功创建订单
|
||||||
|
func TestPurchase_NewUserOnly_Success(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(3)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 用户 1 小时前注册(新用户),且没有历史订单
|
||||||
|
u := insertTestUser(t, db, 300, time.Now().Add(-1*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.NotEmpty(t, resp.OrderNo, "新用户首次购买应成功,返回订单号")
|
||||||
|
|
||||||
|
// 验证订单已写入数据库
|
||||||
|
var o modelOrder.Order
|
||||||
|
err = db.Where("order_no = ?", resp.OrderNo).First(&o).Error
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, u.Id, o.UserId)
|
||||||
|
assert.Equal(t, subID, o.SubscribeId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_Disabled 验证:new_user_only=false 时,老用户也能正常购买
|
||||||
|
func TestPurchase_NewUserOnly_Disabled(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(4)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, false) // new_user_only = false
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 注册 30 天的老用户
|
||||||
|
u := insertTestUser(t, db, 400, time.Now().Add(-30*24*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.NotEmpty(t, resp.OrderNo, "new_user_only=false时老用户应能正常购买")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_SingleMode_PendingOldOrderCancelled 验证:单订阅模式下,已有 pending 订单时
|
||||||
|
// 第二次下单应关闭旧单并创建新单(而非复用旧单)
|
||||||
|
func TestPurchase_SingleMode_PendingOldOrderCancelled(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
svcCtx.Config.Subscribe.SingleModel = true
|
||||||
|
|
||||||
|
const subID = int64(5)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, false)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
u := insertTestUser(t, db, 500, time.Now().Add(-1*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
// 第一次下单(pending)
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp1, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp1)
|
||||||
|
firstOrderNo := resp1.OrderNo
|
||||||
|
assert.NotEmpty(t, firstOrderNo)
|
||||||
|
|
||||||
|
// 确认第一单 pending
|
||||||
|
var firstOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&firstOrder).Error)
|
||||||
|
assert.Equal(t, uint8(1), firstOrder.Status, "第一单应为 pending")
|
||||||
|
|
||||||
|
// 第二次下单(不同 quantity)
|
||||||
|
logic2 := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp2, err := logic2.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 3,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp2)
|
||||||
|
secondOrderNo := resp2.OrderNo
|
||||||
|
|
||||||
|
// 新单与旧单不同
|
||||||
|
assert.NotEqual(t, firstOrderNo, secondOrderNo, "第二次下单应创建新订单,不复用旧单")
|
||||||
|
|
||||||
|
// 旧单应被关闭(status=3)
|
||||||
|
var closedOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", firstOrderNo).First(&closedOrder).Error)
|
||||||
|
assert.Equal(t, uint8(3), closedOrder.Status, "旧 pending 单应被关闭")
|
||||||
|
|
||||||
|
// 新单的 quantity 应为 3
|
||||||
|
var newOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", secondOrderNo).First(&newOrder).Error)
|
||||||
|
assert.Equal(t, int64(3), newOrder.Quantity, "新单 quantity 应为 3")
|
||||||
|
assert.Equal(t, uint8(1), newOrder.Status, "新单应为 pending 状态")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_SingleMode_NoPendingOrder 验证:单订阅模式下,没有旧 pending 单时正常创建
|
||||||
|
func TestPurchase_SingleMode_NoPendingOrder(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
svcCtx.Config.Subscribe.SingleModel = true
|
||||||
|
|
||||||
|
const subID = int64(6)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, false)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
u := insertTestUser(t, db, 600, time.Now().Add(-1*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 2,
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.NotEmpty(t, resp.OrderNo, "无旧 pending 单时应正常创建新单")
|
||||||
|
|
||||||
|
var o modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&o).Error)
|
||||||
|
assert.Equal(t, int64(2), o.Quantity)
|
||||||
|
assert.Equal(t, uint8(1), o.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock 验证:new_user_only=true 套餐,
|
||||||
|
// 24小时内但已购买过 → 允许下单,但不享受新人折扣(Discount=0,Amount=Price)
|
||||||
|
func TestPurchase_NewUserOnly_AlreadyPurchased_NoBlock(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(7)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
// 套餐:unit_price=1000,discount=[{quantity:1,discount:80,new_user_only:true}]
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 用户 1 小时前注册(新用户),但已有一条成功订单(status=2)
|
||||||
|
u := insertTestUser(t, db, 700, time.Now().Add(-1*time.Hour))
|
||||||
|
insertTestOrder(t, db, u.Id, subID, 2)
|
||||||
|
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 不应被拦截
|
||||||
|
require.NoError(t, err, "24h内已购用户不应被拦截,应允许下单")
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.NotEmpty(t, resp.OrderNo)
|
||||||
|
|
||||||
|
// 验证订单金额:无折扣,Amount=Price=1000,Discount=0
|
||||||
|
var newOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
|
||||||
|
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
|
||||||
|
assert.Equal(t, int64(1000), newOrder.Amount, "已购用户不享受新人折扣,Amount 应等于 Price")
|
||||||
|
assert.Equal(t, int64(0), newOrder.Discount, "Discount 应为 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestPurchase_NewUserOnly_FirstPurchase_HasDiscount 验证:new_user_only=true 套餐,
|
||||||
|
// 24小时内首次购买 → 允许下单且享受新人折扣
|
||||||
|
func TestPurchase_NewUserOnly_FirstPurchase_HasDiscount(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const subID = int64(8)
|
||||||
|
const payID = int64(1)
|
||||||
|
|
||||||
|
// 套餐:unit_price=1000,discount=[{quantity:1,discount:80,new_user_only:true}](8折)
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
// 用户 1 小时前注册,无历史订单
|
||||||
|
u := insertTestUser(t, db, 800, time.Now().Add(-1*time.Hour))
|
||||||
|
ctx := buildPurchaseCtx(u)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(ctx, svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
|
var newOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
|
||||||
|
assert.Equal(t, int64(1000), newOrder.Price, "Price 应为原价 1000")
|
||||||
|
assert.Equal(t, int64(900), newOrder.Amount, "首次购买应享受9折,Amount=900")
|
||||||
|
assert.Equal(t, int64(100), newOrder.Discount, "折扣金额应为 100")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchase_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subID = int64(9)
|
||||||
|
payID = int64(1)
|
||||||
|
ownerUserID = int64(901)
|
||||||
|
memberUserID = int64(902)
|
||||||
|
familyID = int64(99)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertTestUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
|
||||||
|
insertTestDevice(t, db, memberUserID, "device-eligibility-old", time.Now().Add(-72*time.Hour))
|
||||||
|
insertTestFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
|
||||||
|
_, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
var errCode *xerr.CodeError
|
||||||
|
require.ErrorAs(t, err, &errCode)
|
||||||
|
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPurchase_NewUserOnly_BindEmailScopeSharesHistory(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subID = int64(10)
|
||||||
|
payID = int64(1)
|
||||||
|
ownerUserID = int64(1001)
|
||||||
|
memberUserID = int64(1002)
|
||||||
|
familyID = int64(109)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
insertTestPayment(t, db, payID)
|
||||||
|
|
||||||
|
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertTestUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
|
||||||
|
insertTestDevice(t, db, memberUserID, "device-eligibility-shared", time.Now().Add(-2*time.Hour))
|
||||||
|
insertTestFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||||
|
insertScopedTestOrder(t, db, "existing-scope-order", memberUserID, subID, 2)
|
||||||
|
|
||||||
|
logic := NewPurchaseLogic(buildPurchaseCtx(owner), svcCtx)
|
||||||
|
resp, err := logic.Purchase(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Payment: payID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
|
||||||
|
var newOrder modelOrder.Order
|
||||||
|
require.NoError(t, db.Where("order_no = ?", resp.OrderNo).First(&newOrder).Error)
|
||||||
|
assert.Equal(t, int64(1000), newOrder.Amount)
|
||||||
|
assert.Equal(t, int64(0), newOrder.Discount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreCreateOrder_NewUserOnly_BindEmailScopeUsesEarliestDeviceTime(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subID = int64(11)
|
||||||
|
ownerUserID = int64(1101)
|
||||||
|
memberUserID = int64(1102)
|
||||||
|
familyID = int64(119)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
|
||||||
|
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
|
||||||
|
insertTestDevice(t, db, memberUserID, "device-precreate-old", time.Now().Add(-96*time.Hour))
|
||||||
|
insertTestFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||||
|
|
||||||
|
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
|
||||||
|
_, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
var errCode *xerr.CodeError
|
||||||
|
require.ErrorAs(t, err, &errCode)
|
||||||
|
assert.Equal(t, xerr.SubscribeNewUserOnly, errCode.GetErrCode())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreCreateOrder_NewUserOnly_OrdinaryFamilyMemberDoesNotAffectEligibility(t *testing.T) {
|
||||||
|
db := setupNewUserOnlyDB(t)
|
||||||
|
rds, mr := setupNewUserOnlyRedis(t)
|
||||||
|
svcCtx := buildNewUserOnlySvcCtx(db, rds, mr)
|
||||||
|
|
||||||
|
const (
|
||||||
|
subID = int64(12)
|
||||||
|
ownerUserID = int64(1201)
|
||||||
|
memberUserID = int64(1202)
|
||||||
|
familyID = int64(129)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertTestSubscribe(t, db, subID, true)
|
||||||
|
|
||||||
|
owner := insertTestUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertTestUser(t, db, memberUserID, time.Now().Add(-96*time.Hour))
|
||||||
|
insertTestDevice(t, db, memberUserID, "device-precreate-ordinary", time.Now().Add(-96*time.Hour))
|
||||||
|
insertTestFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertTestFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertTestFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "manual_invite")
|
||||||
|
|
||||||
|
logic := NewPreCreateOrderLogic(buildPurchaseCtx(owner), svcCtx)
|
||||||
|
resp, err := logic.PreCreateOrder(&types.PurchaseOrderRequest{
|
||||||
|
SubscribeId: subID,
|
||||||
|
Quantity: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, resp)
|
||||||
|
assert.Equal(t, int64(900), resp.Amount)
|
||||||
|
assert.Equal(t, int64(100), resp.Discount)
|
||||||
|
}
|
||||||
@ -54,6 +54,17 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
|||||||
if entErr != nil {
|
if entErr != nil {
|
||||||
return nil, entErr
|
return nil, entErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_create_start",
|
||||||
|
"[SubscriptionFlow] renewal order creation started",
|
||||||
|
logger.Field("order_kind", "renewal"),
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("effective_user_id", entitlement.EffectiveUserID),
|
||||||
|
logger.Field("requested_user_subscribe_id", req.UserSubscribeID),
|
||||||
|
logger.Field("quantity", req.Quantity),
|
||||||
|
logger.Field("payment_id", req.Payment),
|
||||||
|
logger.Field("coupon", req.Coupon),
|
||||||
|
)
|
||||||
if req.Quantity <= 0 {
|
if req.Quantity <= 0 {
|
||||||
l.Debugf("[Renewal] Quantity is less than or equal to 0, setting to 1")
|
l.Debugf("[Renewal] Quantity is less than or equal to 0, setting to 1")
|
||||||
req.Quantity = 1
|
req.Quantity = 1
|
||||||
@ -180,22 +191,22 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
|||||||
UserId: u.Id,
|
UserId: u.Id,
|
||||||
SubscriptionUserId: entitlement.EffectiveUserID,
|
SubscriptionUserId: entitlement.EffectiveUserID,
|
||||||
ParentId: userSubscribe.OrderId,
|
ParentId: userSubscribe.OrderId,
|
||||||
OrderNo: orderNo,
|
OrderNo: orderNo,
|
||||||
Type: 2,
|
Type: 2,
|
||||||
Quantity: req.Quantity,
|
Quantity: req.Quantity,
|
||||||
Price: price,
|
Price: price,
|
||||||
Amount: amount,
|
Amount: amount,
|
||||||
GiftAmount: deductionAmount,
|
GiftAmount: deductionAmount,
|
||||||
Discount: discountAmount,
|
Discount: discountAmount,
|
||||||
Coupon: req.Coupon,
|
Coupon: req.Coupon,
|
||||||
CouponDiscount: coupon,
|
CouponDiscount: coupon,
|
||||||
PaymentId: payment.Id,
|
PaymentId: payment.Id,
|
||||||
Method: canonicalOrderMethod(payment.Platform),
|
Method: canonicalOrderMethod(payment.Platform),
|
||||||
FeeAmount: feeAmount,
|
FeeAmount: feeAmount,
|
||||||
Status: 1,
|
Status: 1,
|
||||||
SubscribeId: userSubscribe.SubscribeId,
|
SubscribeId: userSubscribe.SubscribeId,
|
||||||
SubscribeToken: userSubscribe.Token,
|
SubscribeToken: userSubscribe.Token,
|
||||||
AppAccountToken: uuid.New().String(),
|
AppAccountToken: uuid.New().String(),
|
||||||
}
|
}
|
||||||
// Database transaction
|
// Database transaction
|
||||||
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
err = l.svcCtx.DB.Transaction(func(db *gorm.DB) error {
|
||||||
@ -235,6 +246,14 @@ func (l *RenewalLogic) Renewal(req *types.RenewalOrderRequest) (resp *types.Rene
|
|||||||
l.Errorw("[Renewal] Database insert error", logger.Field("error", err.Error()), logger.Field("order", orderInfo))
|
l.Errorw("[Renewal] Database insert error", logger.Field("error", err.Error()), logger.Field("order", orderInfo))
|
||||||
return nil, errors.Wrapf(err, "insert order error: %v", err.Error())
|
return nil, errors.Wrapf(err, "insert order error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "order_created",
|
||||||
|
"[SubscriptionFlow] renewal order persisted",
|
||||||
|
append(commonLogic.OrderTraceFields(&orderInfo),
|
||||||
|
logger.Field("requested_user_subscribe_id", req.UserSubscribeID),
|
||||||
|
logger.Field("resolved_user_subscribe_id", userSubscribe.Id),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
// Deferred task
|
// Deferred task
|
||||||
payload := queue.DeferCloseOrderPayload{
|
payload := queue.DeferCloseOrderPayload{
|
||||||
OrderNo: orderInfo.OrderNo,
|
OrderNo: orderInfo.OrderNo,
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/internal/report"
|
"github.com/perfect-panel/server/internal/report"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
@ -75,6 +76,14 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
|
|||||||
l.Logger.Error("[PurchaseCheckout] Database query error", logger.Field("error", err.Error()), logger.Field("payment", orderInfo.Method))
|
l.Logger.Error("[PurchaseCheckout] Database query error", logger.Field("error", err.Error()), logger.Field("payment", orderInfo.Method))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error())
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "find payment method error: %v", err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "checkout_start",
|
||||||
|
"[SubscriptionFlow] checkout started",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", paymentConfig.Platform),
|
||||||
|
logger.Field("has_return_url", req.ReturnUrl != ""),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
// Route to appropriate payment handler based on payment platform
|
// Route to appropriate payment handler based on payment platform
|
||||||
switch paymentPlatform.ParsePlatform(orderInfo.Method) {
|
switch paymentPlatform.ParsePlatform(orderInfo.Method) {
|
||||||
case paymentPlatform.AppleIAP:
|
case paymentPlatform.AppleIAP:
|
||||||
@ -83,6 +92,14 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
|
|||||||
Type: "apple_iap",
|
Type: "apple_iap",
|
||||||
ProductIds: []string{productId},
|
ProductIds: []string{productId},
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "checkout_response_ready",
|
||||||
|
"[SubscriptionFlow] checkout response prepared",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", paymentConfig.Platform),
|
||||||
|
logger.Field("checkout_type", resp.Type),
|
||||||
|
logger.Field("product_ids", resp.ProductIds),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
return resp, nil
|
return resp, nil
|
||||||
case paymentPlatform.EPay:
|
case paymentPlatform.EPay:
|
||||||
// Process EPay payment - generates payment URL for redirect
|
// Process EPay payment - generates payment URL for redirect
|
||||||
@ -157,6 +174,16 @@ func (l *PurchaseCheckoutLogic) PurchaseCheckout(req *types.CheckoutOrderRequest
|
|||||||
l.Errorw("[PurchaseCheckout] payment method not found", logger.Field("method", orderInfo.Method))
|
l.Errorw("[PurchaseCheckout] payment method not found", logger.Field("method", orderInfo.Method))
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "payment method not found")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.ERROR), "payment method not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if resp != nil {
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "checkout_response_ready",
|
||||||
|
"[SubscriptionFlow] checkout response prepared",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("payment_platform", paymentConfig.Platform),
|
||||||
|
logger.Field("checkout_type", resp.Type),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -503,6 +530,9 @@ func (l *PurchaseCheckoutLogic) queryExchangeRate(to string, src int64) (amount
|
|||||||
func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) error {
|
func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) error {
|
||||||
var userInfo user.User
|
var userInfo user.User
|
||||||
var err error
|
var err error
|
||||||
|
var giftUsed int64
|
||||||
|
var balanceUsed int64
|
||||||
|
paymentPath := "balance"
|
||||||
if o.Amount == 0 {
|
if o.Amount == 0 {
|
||||||
// No payment required for zero-amount orders
|
// No payment required for zero-amount orders
|
||||||
l.Logger.Info(
|
l.Logger.Info(
|
||||||
@ -518,6 +548,13 @@ func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) err
|
|||||||
logger.Field("userId", u.Id))
|
logger.Field("userId", u.Id))
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Update order status error: %s", err.Error())
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseQueryError), "Update order status error: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] order marked paid without external payment",
|
||||||
|
append(commonLogic.OrderTraceFields(o),
|
||||||
|
logger.Field("payment_path", "zero_amount"),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
paymentPath = "zero_amount"
|
||||||
goto activation
|
goto activation
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -536,7 +573,6 @@ func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) err
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Calculate payment distribution: prioritize gift amount first
|
// Calculate payment distribution: prioritize gift amount first
|
||||||
var giftUsed, balanceUsed int64
|
|
||||||
remainingAmount := o.Amount
|
remainingAmount := o.Amount
|
||||||
|
|
||||||
if userInfo.GiftAmount >= remainingAmount {
|
if userInfo.GiftAmount >= remainingAmount {
|
||||||
@ -621,6 +657,15 @@ func (l *PurchaseCheckoutLogic) balancePayment(u *user.User, o *order.Order) err
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "payment_settled",
|
||||||
|
"[SubscriptionFlow] balance payment settled and order marked paid",
|
||||||
|
append(commonLogic.OrderTraceFields(o),
|
||||||
|
logger.Field("payment_path", "balance"),
|
||||||
|
logger.Field("gift_used", giftUsed),
|
||||||
|
logger.Field("balance_used", balanceUsed),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
activation:
|
activation:
|
||||||
// Enqueue order activation task for immediate processing
|
// Enqueue order activation task for immediate processing
|
||||||
payload := queueType.ForthwithActivateOrderPayload{
|
payload := queueType.ForthwithActivateOrderPayload{
|
||||||
@ -639,6 +684,13 @@ activation:
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowOrder, "activation_task_enqueued",
|
||||||
|
"[SubscriptionFlow] activation task enqueued after checkout payment",
|
||||||
|
append(commonLogic.OrderTraceFields(o),
|
||||||
|
logger.Field("payment_path", paymentPath),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
l.Logger.Info("[PurchaseCheckout] Balance payment completed successfully",
|
l.Logger.Info("[PurchaseCheckout] Balance payment completed successfully",
|
||||||
logger.Field("orderNo", o.OrderNo),
|
logger.Field("orderNo", o.OrderNo),
|
||||||
logger.Field("userId", u.Id))
|
logger.Field("userId", u.Id))
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@ -43,6 +44,12 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.InvalidAccess), "Invalid Access")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "bind_start",
|
||||||
|
"[SubscriptionFlow] email bind with verification started",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
|
|
||||||
type payload struct {
|
type payload struct {
|
||||||
Code string `json:"code"`
|
Code string `json:"code"`
|
||||||
LastAt int64 `json:"lastAt"`
|
LastAt int64 `json:"lastAt"`
|
||||||
@ -69,6 +76,12 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code error or expired")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "bind_code_verified",
|
||||||
|
"[SubscriptionFlow] email verification code accepted",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
|
|
||||||
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
|
familyHelper := newFamilyBindingHelper(l.ctx, l.svcCtx)
|
||||||
currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id)
|
currentEmailMethod, err := familyHelper.getUserEmailMethod(u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -115,6 +128,13 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
return nil, txErr
|
return nil, txErr
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "email_owner_created",
|
||||||
|
"[SubscriptionFlow] new email owner account created for bind flow",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", emailUser.Id),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
|
|
||||||
// Join family: email user as owner, device user as member
|
// Join family: email user as owner, device user as member
|
||||||
if err = familyHelper.validateJoinFamily(emailUser.Id, u.Id); err != nil {
|
if err = familyHelper.validateJoinFamily(emailUser.Id, u.Id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@ -123,11 +143,32 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "family_joined",
|
||||||
|
"[SubscriptionFlow] device user joined email owner family",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", emailUser.Id),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
token, err := l.refreshBindSessionToken(u.Id)
|
token, err := l.refreshBindSessionToken(u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_requested",
|
||||||
|
"[SubscriptionFlow] evaluating trial grant after email bind",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", emailUser.Id),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, emailUser.Id, req.Email)
|
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, emailUser.Id, req.Email)
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "bind_complete",
|
||||||
|
"[SubscriptionFlow] email bind with verification completed",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", emailUser.Id),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
return &types.BindEmailWithVerificationResponse{
|
return &types.BindEmailWithVerificationResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
Message: "email user created and joined family",
|
Message: "email user created and joined family",
|
||||||
@ -146,16 +187,44 @@ func (l *BindEmailWithVerificationLogic) BindEmailWithVerification(req *types.Bi
|
|||||||
if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil {
|
if err = familyHelper.validateJoinFamily(existingMethod.UserId, u.Id); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "email_owner_resolved",
|
||||||
|
"[SubscriptionFlow] existing email owner resolved for bind flow",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", existingMethod.UserId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification")
|
joinResult, err := familyHelper.joinFamily(existingMethod.UserId, u.Id, "bind_email_with_verification")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "family_joined",
|
||||||
|
"[SubscriptionFlow] device user joined existing email owner family",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", existingMethod.UserId),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
token, err := l.refreshBindSessionToken(u.Id)
|
token, err := l.refreshBindSessionToken(u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_requested",
|
||||||
|
"[SubscriptionFlow] evaluating trial grant after existing email owner bind",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", existingMethod.UserId),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, existingMethod.UserId, req.Email)
|
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, existingMethod.UserId, req.Email)
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "bind_complete",
|
||||||
|
"[SubscriptionFlow] email bind with verification completed",
|
||||||
|
logger.Field("device_user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", existingMethod.UserId),
|
||||||
|
logger.Field("family_id", joinResult.FamilyId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
|
|
||||||
return &types.BindEmailWithVerificationResponse{
|
return &types.BindEmailWithVerificationResponse{
|
||||||
Success: true,
|
Success: true,
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/logic/auth"
|
"github.com/perfect-panel/server/internal/logic/auth"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@ -14,11 +15,28 @@ import (
|
|||||||
|
|
||||||
func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, log logger.Logger, ownerUserId int64, email string) {
|
func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, log logger.Logger, ownerUserId int64, email string) {
|
||||||
rc := svcCtx.Config.Register
|
rc := svcCtx.Config.Register
|
||||||
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_evaluating",
|
||||||
|
"[SubscriptionFlow] evaluating email bind trial grant",
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("trial_subscribe_id", rc.TrialSubscribe),
|
||||||
|
)
|
||||||
|
if !auth.ShouldAutoGrantTrialOnPublicEmailFlows(rc) {
|
||||||
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_skipped",
|
||||||
|
"[SubscriptionFlow] auto trial on public email flow disabled",
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("skip_reason", "public_email_trial_disabled"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
if !auth.ShouldGrantTrialForEmail(rc, email) {
|
if !auth.ShouldGrantTrialForEmail(rc, email) {
|
||||||
if rc.EnableTrial && rc.EnableTrialEmailWhitelist {
|
if rc.EnableTrial && rc.EnableTrialEmailWhitelist {
|
||||||
log.Infow("email domain not in trial whitelist, skip",
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_skipped",
|
||||||
|
"[SubscriptionFlow] email domain not in trial whitelist",
|
||||||
logger.Field("email", email),
|
logger.Field("email", email),
|
||||||
logger.Field("owner_user_id", ownerUserId),
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("skip_reason", "trial_whitelist_rejected"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
@ -29,19 +47,45 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l
|
|||||||
Model(&user.Subscribe{}).
|
Model(&user.Subscribe{}).
|
||||||
Where("user_id = ? AND subscribe_id = ?", ownerUserId, rc.TrialSubscribe).
|
Where("user_id = ? AND subscribe_id = ?", ownerUserId, rc.TrialSubscribe).
|
||||||
Count(&count).Error; err != nil {
|
Count(&count).Error; err != nil {
|
||||||
log.Errorw("failed to check existing trial", logger.Field("error", err.Error()))
|
commonLogic.SubscriptionTraceError(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_error",
|
||||||
|
"[SubscriptionFlow] failed to query existing trial subscription",
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if count > 0 {
|
if count > 0 {
|
||||||
log.Infow("trial already granted, skip",
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_skipped",
|
||||||
|
"[SubscriptionFlow] trial already exists for owner",
|
||||||
logger.Field("owner_user_id", ownerUserId),
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("skip_reason", "trial_already_exists"),
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cross-user check: prevent the same real inbox (via dot trick / + alias) from
|
||||||
|
// getting multiple trials across different accounts.
|
||||||
|
if auth.NormalizedEmailHasTrial(ctx, svcCtx.DB, email, rc.TrialSubscribe) {
|
||||||
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_skipped",
|
||||||
|
"[SubscriptionFlow] normalized email already received a trial elsewhere",
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("skip_reason", "normalized_email_has_trial"),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
|
sub, err := svcCtx.SubscribeModel.FindOne(ctx, rc.TrialSubscribe)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Errorw("failed to find trial subscribe template", logger.Field("error", err.Error()))
|
commonLogic.SubscriptionTraceError(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_error",
|
||||||
|
"[SubscriptionFlow] failed to load trial subscription template",
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("trial_subscribe_id", rc.TrialSubscribe),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,9 +103,13 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l
|
|||||||
Status: 1,
|
Status: 1,
|
||||||
}
|
}
|
||||||
if err = svcCtx.UserModel.InsertSubscribe(ctx, userSub); err != nil {
|
if err = svcCtx.UserModel.InsertSubscribe(ctx, userSub); err != nil {
|
||||||
log.Errorw("failed to insert trial subscribe",
|
commonLogic.SubscriptionTraceError(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_error",
|
||||||
logger.Field("error", err.Error()),
|
"[SubscriptionFlow] failed to create trial subscription for email bind",
|
||||||
logger.Field("owner_user_id", ownerUserId),
|
append(commonLogic.UserSubscribeTraceFields(userSub),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
)...,
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -72,9 +120,12 @@ func tryGrantTrialOnEmailBind(ctx context.Context, svcCtx *svc.ServiceContext, l
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Infow("trial granted on email bind",
|
commonLogic.SubscriptionTraceInfo(log, commonLogic.SubscriptionTraceFlowEmailBind, "trial_grant_succeeded",
|
||||||
logger.Field("owner_user_id", ownerUserId),
|
"[SubscriptionFlow] trial subscription granted after email bind",
|
||||||
logger.Field("email", email),
|
append(commonLogic.UserSubscribeTraceFields(userSub),
|
||||||
logger.Field("subscribe_id", sub.Id),
|
logger.Field("owner_user_id", ownerUserId),
|
||||||
|
logger.Field("email", email),
|
||||||
|
logger.Field("trial_subscribe_id", sub.Id),
|
||||||
|
)...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -209,10 +209,9 @@ func transferMemberSubscribesToOwner(tx *gorm.DB, memberUserId, ownerUserId int6
|
|||||||
if len(subscribes) == 0 {
|
if len(subscribes) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
if err := tx.Model(&user.Subscribe{}).
|
// 加入家庭组时,无条件丢弃成员的所有订阅(软删除)
|
||||||
Where("user_id = ?", memberUserId).
|
if err := tx.Where("user_id = ?", memberUserId).Delete(&user.Subscribe{}).Error; err != nil {
|
||||||
Update("user_id", ownerUserId).Error; err != nil {
|
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "discard member subscribes failed")
|
||||||
return nil, errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "transfer member subscribes to owner failed")
|
|
||||||
}
|
}
|
||||||
return subscribes, nil
|
return subscribes, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/config"
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/user"
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
"github.com/perfect-panel/server/internal/svc"
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
"github.com/perfect-panel/server/internal/types"
|
"github.com/perfect-panel/server/internal/types"
|
||||||
@ -39,6 +40,10 @@ type CacheKeyPayload struct {
|
|||||||
|
|
||||||
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
||||||
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
req.Email = strings.ToLower(strings.TrimSpace(req.Email))
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "verify_email_start",
|
||||||
|
"[SubscriptionFlow] email verification started",
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
|
cacheKey := fmt.Sprintf("%s:%s:%s", config.AuthCodeCacheKey, constant.Security, req.Email)
|
||||||
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
value, err := l.svcCtx.Redis.Get(l.ctx, cacheKey).Result()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -59,6 +64,10 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
|||||||
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
return errors.Wrapf(xerr.NewErrCode(xerr.VerifyCodeError), "code expired")
|
||||||
}
|
}
|
||||||
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
l.svcCtx.Redis.Del(l.ctx, cacheKey)
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "verify_email_code_verified",
|
||||||
|
"[SubscriptionFlow] email verification code accepted",
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
|
|
||||||
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
u, ok := l.ctx.Value(constant.CtxKeyUser).(*user.User)
|
||||||
if !ok {
|
if !ok {
|
||||||
@ -77,6 +86,12 @@ func (l *VerifyEmailLogic) VerifyEmail(req *types.VerifyEmailRequest) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
return errors.Wrapf(xerr.NewErrCode(xerr.DatabaseUpdateError), "UpdateUserAuthMethods error")
|
||||||
}
|
}
|
||||||
|
commonLogic.SubscriptionTraceInfo(l.Logger, commonLogic.SubscriptionTraceFlowEmailBind, "verify_email_completed",
|
||||||
|
"[SubscriptionFlow] email verification completed and trial evaluation will run",
|
||||||
|
logger.Field("user_id", u.Id),
|
||||||
|
logger.Field("owner_user_id", method.UserId),
|
||||||
|
logger.Field("email", req.Email),
|
||||||
|
)
|
||||||
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, method.UserId, req.Email)
|
tryGrantTrialOnEmailBind(l.ctx, l.svcCtx, l.Logger, method.UserId, req.Email)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@ -166,12 +166,11 @@ func (m *customOrderModel) QueryMonthlyOrders(ctx context.Context, date time.Tim
|
|||||||
|
|
||||||
// QueryDateOrders Query orders by date
|
// QueryDateOrders Query orders by date
|
||||||
func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) (OrdersTotal, error) {
|
func (m *customOrderModel) QueryDateOrders(ctx context.Context, date time.Time) (OrdersTotal, error) {
|
||||||
start := date.Truncate(24 * time.Hour)
|
dateStr := date.Format("2006-01-02")
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Nanosecond)
|
|
||||||
var result OrdersTotal
|
var result OrdersTotal
|
||||||
err := m.QueryNoCacheCtx(ctx, &result, func(conn *gorm.DB, v interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, &result, func(conn *gorm.DB, v interface{}) error {
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Where("status IN ? AND created_at BETWEEN ? AND ? AND method != ?", []int64{2, 5}, start, end, "balance").
|
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?", []int64{2, 5}, dateStr, "balance").
|
||||||
Select(
|
Select(
|
||||||
"SUM(amount) as amount_total, " +
|
"SUM(amount) as amount_total, " +
|
||||||
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
"SUM(CASE WHEN is_new = 1 THEN amount ELSE 0 END) as new_order_amount, " +
|
||||||
@ -222,10 +221,7 @@ func (m *customOrderModel) QueryMonthlyUserCounts(ctx context.Context, date time
|
|||||||
return counts.NewUsers, counts.RenewalUsers, err
|
return counts.NewUsers, counts.RenewalUsers, err
|
||||||
}
|
}
|
||||||
func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Time) (int64, int64, error) {
|
func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Time) (int64, int64, error) {
|
||||||
// 当天 00:00:00
|
dateStr := date.Format("2006-01-02")
|
||||||
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
|
||||||
// 下一天 00:00:00
|
|
||||||
nextDay := start.Add(24 * time.Hour)
|
|
||||||
|
|
||||||
var counts UserCounts
|
var counts UserCounts
|
||||||
|
|
||||||
@ -235,8 +231,8 @@ func (m *customOrderModel) QueryDateUserCounts(ctx context.Context, date time.Ti
|
|||||||
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
COUNT(DISTINCT CASE WHEN is_new = 1 THEN user_id END) AS new_users,
|
||||||
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
COUNT(DISTINCT CASE WHEN is_new = 0 THEN user_id END) AS renewal_users
|
||||||
`).
|
`).
|
||||||
Where("status IN ? AND created_at >= ? AND created_at < ? AND method != ?",
|
Where("status IN ? AND DATE_FORMAT(created_at, '%Y-%m-%d') = ? AND method != ?",
|
||||||
[]int64{2, 5}, start, nextDay, "balance").
|
[]int64{2, 5}, dateStr, "balance").
|
||||||
Scan(&counts).Error
|
Scan(&counts).Error
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -276,7 +272,7 @@ func (m *customOrderModel) QueryDailyOrdersList(ctx context.Context, date time.T
|
|||||||
// 当月 1 号 00:00:00
|
// 当月 1 号 00:00:00
|
||||||
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
firstDay := time.Date(date.Year(), date.Month(), 1, 0, 0, 0, 0, date.Location())
|
||||||
// 第二天 00:00:00
|
// 第二天 00:00:00
|
||||||
nextDay := date.AddDate(0, 0, 1).Truncate(24 * time.Hour)
|
nextDay := time.Date(date.Year(), date.Month(), date.Day()+1, 0, 0, 0, 0, date.Location())
|
||||||
|
|
||||||
return conn.Model(&Order{}).
|
return conn.Model(&Order{}).
|
||||||
Select(`
|
Select(`
|
||||||
|
|||||||
@ -27,8 +27,8 @@ func NewModel(conn *gorm.DB) Model {
|
|||||||
|
|
||||||
func (m *customTrafficModel) QueryServerTrafficByDay(ctx context.Context, serverId int64, date time.Time) (*TotalTraffic, error) {
|
func (m *customTrafficModel) QueryServerTrafficByDay(ctx context.Context, serverId int64, date time.Time) (*TotalTraffic, error) {
|
||||||
var data TotalTraffic
|
var data TotalTraffic
|
||||||
start := date.Truncate(24 * time.Hour)
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Nanosecond)
|
end := start.AddDate(0, 0, 1).Add(-time.Nanosecond)
|
||||||
err := m.Conn.WithContext(ctx).Model(&TrafficLog{}).
|
err := m.Conn.WithContext(ctx).Model(&TrafficLog{}).
|
||||||
Select("sum(download) as download, sum(upload) as upload").
|
Select("sum(download) as download, sum(upload) as upload").
|
||||||
Where("server_id = ? AND timestamp BETWEEN ? AND ?", serverId, start, end).
|
Where("server_id = ? AND timestamp BETWEEN ? AND ?", serverId, start, end).
|
||||||
|
|||||||
@ -314,8 +314,8 @@ func (m *customUserModel) UpdateUserSubscribeWithTraffic(ctx context.Context, id
|
|||||||
|
|
||||||
func (m *customUserModel) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) {
|
func (m *customUserModel) QueryResisterUserTotalByDate(ctx context.Context, date time.Time) (int64, error) {
|
||||||
var total int64
|
var total int64
|
||||||
start := date.Truncate(24 * time.Hour)
|
start := time.Date(date.Year(), date.Month(), date.Day(), 0, 0, 0, 0, date.Location())
|
||||||
end := start.Add(24 * time.Hour).Add(-time.Second)
|
end := start.AddDate(0, 0, 1).Add(-time.Second)
|
||||||
err := m.QueryNoCacheCtx(ctx, &total, func(conn *gorm.DB, v interface{}) error {
|
err := m.QueryNoCacheCtx(ctx, &total, func(conn *gorm.DB, v interface{}) error {
|
||||||
return conn.Model(&User{}).Where("created_at > ? and created_at < ?", start, end).Count(&total).Error
|
return conn.Model(&User{}).Where("created_at > ? and created_at < ?", start, end).Count(&total).Error
|
||||||
})
|
})
|
||||||
|
|||||||
@ -130,16 +130,17 @@ func (*AuthMethods) TableName() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Device struct {
|
type Device struct {
|
||||||
Id int64 `gorm:"primaryKey"`
|
Id int64 `gorm:"primaryKey"`
|
||||||
Ip string `gorm:"type:varchar(255);not null;comment:Device IP"`
|
Ip string `gorm:"type:varchar(255);not null;comment:Device IP"`
|
||||||
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
UserId int64 `gorm:"index:idx_user_id;not null;comment:User ID"`
|
||||||
UserAgent string `gorm:"default:null;comment:UserAgent."`
|
UserAgent string `gorm:"default:null;comment:UserAgent."`
|
||||||
Identifier string `gorm:"type:varchar(255);unique;index:idx_identifier;default:'';comment:Device Identifier"`
|
Identifier string `gorm:"type:varchar(255);unique;index:idx_identifier;default:'';comment:Device Identifier"`
|
||||||
ShortCode string `gorm:"type:varchar(255);default:'';comment:Short Code"`
|
ShortCode string `gorm:"type:varchar(255);default:'';comment:Short Code"`
|
||||||
Online bool `gorm:"default:false;not null;comment:Online"`
|
BasePayload string `gorm:"type:text;default:null;comment:Base Payload"`
|
||||||
Enabled bool `gorm:"default:true;not null;comment:Enabled"`
|
Online bool `gorm:"default:false;not null;comment:Online"`
|
||||||
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
Enabled bool `gorm:"default:true;not null;comment:Enabled"`
|
||||||
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
CreatedAt time.Time `gorm:"<-:create;comment:Creation Time"`
|
||||||
|
UpdatedAt time.Time `gorm:"comment:Update Time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (*Device) TableName() string {
|
func (*Device) TableName() string {
|
||||||
|
|||||||
@ -36,9 +36,13 @@ func NewService(svc *svc.ServiceContext) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func initServer(svc *svc.ServiceContext) *gin.Engine {
|
func initServer(svc *svc.ServiceContext) *gin.Engine {
|
||||||
|
|
||||||
// start init system config
|
// start init system config
|
||||||
|
initStart := time.Now()
|
||||||
|
logger.Info("system initialization start")
|
||||||
initialize.StartInitSystemConfig(svc)
|
initialize.StartInitSystemConfig(svc)
|
||||||
|
logger.Infow("system initialization complete",
|
||||||
|
logger.Field("duration", time.Since(initStart).String()),
|
||||||
|
)
|
||||||
// init gin server
|
// init gin server
|
||||||
r := gin.Default()
|
r := gin.Default()
|
||||||
r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"}
|
r.RemoteIPHeaders = []string{"X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-IP"}
|
||||||
|
|||||||
@ -637,11 +637,12 @@ type DeviceAuthticateConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type DeviceLoginRequest struct {
|
type DeviceLoginRequest struct {
|
||||||
Identifier string `json:"identifier" validate:"required"`
|
Identifier string `json:"identifier" validate:"required"`
|
||||||
IP string `header:"X-Original-Forwarded-For"`
|
IP string `header:"X-Original-Forwarded-For"`
|
||||||
UserAgent string `json:"user_agent" validate:"required"`
|
UserAgent string `json:"user_agent" validate:"required"`
|
||||||
CfToken string `json:"cf_token,optional"`
|
CfToken string `json:"cf_token,optional"`
|
||||||
ShortCode string `json:"short_code,optional"`
|
ShortCode string `json:"short_code,optional"`
|
||||||
|
BasePayload string `json:"base_payload,optional"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DissolveFamilyRequest struct {
|
type DissolveFamilyRequest struct {
|
||||||
@ -2655,6 +2656,7 @@ type SubscribeDiscount struct {
|
|||||||
Quantity int64 `json:"quantity"`
|
Quantity int64 `json:"quantity"`
|
||||||
Discount float64 `json:"discount"`
|
Discount float64 `json:"discount"`
|
||||||
NewUserOnly bool `json:"new_user_only"`
|
NewUserOnly bool `json:"new_user_only"`
|
||||||
|
MapApple string `json:"map_apple"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type SubscribeGroup struct {
|
type SubscribeGroup struct {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type GormLogger struct {
|
type GormLogger struct {
|
||||||
|
SlowThreshold time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG = "[GORM]"
|
const TAG = "[GORM]"
|
||||||
@ -27,24 +28,25 @@ func (l *GormLogger) LogMode(logger.LogLevel) logger.Interface {
|
|||||||
default:
|
default:
|
||||||
sysLevel = "unknown"
|
sysLevel = "unknown"
|
||||||
}
|
}
|
||||||
Infof("%s System Log Level is %s", TAG, sysLevel)
|
Debugf("%s System Log Level is %s", TAG, sysLevel)
|
||||||
return l
|
return l
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GormLogger) Info(ctx context.Context, str string, args ...interface{}) {
|
func (l *GormLogger) Info(ctx context.Context, str string, args ...interface{}) {
|
||||||
WithContext(ctx).WithCallerSkip(6).Infof("%s Info: %s", TAG, str, args)
|
WithContext(ctx).WithCallerSkip(6).Debugf("%s Info: %s", TAG, fmt.Sprintf(str, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GormLogger) Warn(ctx context.Context, str string, args ...interface{}) {
|
func (l *GormLogger) Warn(ctx context.Context, str string, args ...interface{}) {
|
||||||
WithContext(ctx).WithCallerSkip(6).Infof("%s Warn: %s", TAG, str, args)
|
WithContext(ctx).WithCallerSkip(6).Debugf("%s Warn: %s", TAG, fmt.Sprintf(str, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GormLogger) Error(ctx context.Context, str string, args ...interface{}) {
|
func (l *GormLogger) Error(ctx context.Context, str string, args ...interface{}) {
|
||||||
WithContext(ctx).WithCallerSkip(6).Errorf("%s Error: %s", TAG, str, args)
|
WithContext(ctx).WithCallerSkip(6).Errorf("%s Error: %s", TAG, fmt.Sprintf(str, args...))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
|
func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql string, rowsAffected int64), err error) {
|
||||||
sql, rowsAffected := fc()
|
sql, rowsAffected := fc()
|
||||||
|
duration := time.Since(begin)
|
||||||
fields := []LogField{
|
fields := []LogField{
|
||||||
{
|
{
|
||||||
Key: "sql",
|
Key: "sql",
|
||||||
@ -60,8 +62,16 @@ func (l *GormLogger) Trace(ctx context.Context, begin time.Time, fc func() (sql
|
|||||||
Key: "error",
|
Key: "error",
|
||||||
Value: err.Error(),
|
Value: err.Error(),
|
||||||
})
|
})
|
||||||
WithContext(ctx).WithCallerSkip(6).WithDuration(time.Since(begin)).Errorw(TAG, fields...)
|
WithContext(ctx).WithCallerSkip(6).WithDuration(duration).Errorw(TAG, fields...)
|
||||||
} else {
|
return
|
||||||
WithContext(ctx).WithCallerSkip(6).WithDuration(time.Since(begin)).Infow(fmt.Sprintf("%s SQL Executed", TAG), fields...)
|
}
|
||||||
|
|
||||||
|
if l.SlowThreshold > 0 && duration >= l.SlowThreshold {
|
||||||
|
WithContext(ctx).WithCallerSkip(6).WithDuration(duration).Sloww(fmt.Sprintf("%s SQL Slow", TAG), fields...)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if shallLog(DebugLevel) {
|
||||||
|
WithContext(ctx).WithCallerSkip(6).WithDuration(duration).Debugw(fmt.Sprintf("%s SQL Executed", TAG), fields...)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -46,7 +46,9 @@ func ConnectMysql(m Mysql) (*gorm.DB, error) {
|
|||||||
DSN: m.Dsn(),
|
DSN: m.Dsn(),
|
||||||
}
|
}
|
||||||
db, err := gorm.Open(mysql.New(mysqlCfg), &gorm.Config{
|
db, err := gorm.Open(mysql.New(mysqlCfg), &gorm.Config{
|
||||||
Logger: new(logger.GormLogger),
|
Logger: &logger.GormLogger{
|
||||||
|
SlowThreshold: m.GetSlowThreshold(),
|
||||||
|
},
|
||||||
NamingStrategy: schema.NamingStrategy{
|
NamingStrategy: schema.NamingStrategy{
|
||||||
SingularTable: true,
|
SingularTable: true,
|
||||||
},
|
},
|
||||||
|
|||||||
17
ppanel.go
17
ppanel.go
@ -1,6 +1,21 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import "github.com/perfect-panel/server/cmd"
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/cmd"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
// Ensure time.Local matches DSN loc=Asia/Shanghai.
|
||||||
|
// In scratch Docker images, LoadLocation may fail due to missing zoneinfo,
|
||||||
|
// so fall back to FixedZone as a guaranteed alternative.
|
||||||
|
loc, err := time.LoadLocation("Asia/Shanghai")
|
||||||
|
if err != nil {
|
||||||
|
loc = time.FixedZone("CST", 8*3600)
|
||||||
|
}
|
||||||
|
time.Local = loc
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
cmd.Execute()
|
cmd.Execute()
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/perfect-panel/server/internal/logic/admin/group"
|
"github.com/perfect-panel/server/internal/logic/admin/group"
|
||||||
|
commonLogic "github.com/perfect-panel/server/internal/logic/common"
|
||||||
"github.com/perfect-panel/server/internal/model/log"
|
"github.com/perfect-panel/server/internal/model/log"
|
||||||
"github.com/perfect-panel/server/pkg/constant"
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
"github.com/perfect-panel/server/pkg/logger"
|
"github.com/perfect-panel/server/pkg/logger"
|
||||||
@ -27,6 +28,7 @@ import (
|
|||||||
"github.com/perfect-panel/server/pkg/uuidx"
|
"github.com/perfect-panel/server/pkg/uuidx"
|
||||||
queueTypes "github.com/perfect-panel/server/queue/types"
|
queueTypes "github.com/perfect-panel/server/queue/types"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/clause"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Order type constants define the different types of orders that can be processed
|
// Order type constants define the different types of orders that can be processed
|
||||||
@ -44,6 +46,7 @@ const (
|
|||||||
OrderStatusPaid = 2 // Order paid and ready for processing
|
OrderStatusPaid = 2 // Order paid and ready for processing
|
||||||
OrderStatusClose = 3 // Order closed/cancelled
|
OrderStatusClose = 3 // Order closed/cancelled
|
||||||
OrderStatusFailed = 4 // Order processing failed
|
OrderStatusFailed = 4 // Order processing failed
|
||||||
|
OrderStatusClaimed = 4 // Internal transient claim while a worker processes the order
|
||||||
OrderStatusFinished = 5 // Order successfully completed
|
OrderStatusFinished = 5 // Order successfully completed
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -69,8 +72,10 @@ func NewActivateOrderLogic(svc *svc.ServiceContext) *ActivateOrderLogic {
|
|||||||
// It handles the complete workflow of activating a paid order including validation,
|
// It handles the complete workflow of activating a paid order including validation,
|
||||||
// processing based on order type, and finalization.
|
// processing based on order type, and finalization.
|
||||||
func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) error {
|
func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task) error {
|
||||||
logger.WithContext(ctx).Info("[ActivateOrderLogic] 开始处理订单激活任务",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_task_received",
|
||||||
logger.Field("payload", string(task.Payload())))
|
"[SubscriptionFlow] activation task received",
|
||||||
|
logger.Field("payload", string(task.Payload())),
|
||||||
|
)
|
||||||
|
|
||||||
payload, err := l.parsePayload(ctx, task.Payload())
|
payload, err := l.parsePayload(ctx, task.Payload())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -79,10 +84,13 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
return nil // payload 解析失败不重试,因为重试也会失败
|
return nil // payload 解析失败不重试,因为重试也会失败
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithContext(ctx).Info("[ActivateOrderLogic] 正在验证订单",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_order_lookup",
|
||||||
logger.Field("order_no", payload.OrderNo))
|
"[SubscriptionFlow] activation task is loading order",
|
||||||
|
logger.Field("order_no", payload.OrderNo),
|
||||||
|
logger.Field("iap_expire_at", payload.IAPExpireAt),
|
||||||
|
)
|
||||||
|
|
||||||
orderInfo, err := l.validateAndGetOrder(ctx, payload.OrderNo)
|
orderInfo, err := l.claimAndGetOrder(ctx, payload.OrderNo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// 如果订单不存在或状态不对,不重试
|
// 如果订单不存在或状态不对,不重试
|
||||||
if errors.Is(err, ErrInvalidOrderStatus) {
|
if errors.Is(err, ErrInvalidOrderStatus) {
|
||||||
@ -102,12 +110,13 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithContext(ctx).Info("[ActivateOrderLogic] 订单验证通过,开始处理",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_order_claimed",
|
||||||
logger.Field("order_no", orderInfo.OrderNo),
|
"[SubscriptionFlow] activation worker claimed paid order",
|
||||||
logger.Field("order_type", orderInfo.Type),
|
commonLogic.OrderTraceFields(orderInfo)...,
|
||||||
logger.Field("user_id", orderInfo.UserId))
|
)
|
||||||
|
|
||||||
if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil {
|
if err = l.processOrderByType(ctx, orderInfo, payload.IAPExpireAt); err != nil {
|
||||||
|
l.releaseClaim(ctx, orderInfo.OrderNo)
|
||||||
logger.WithContext(ctx).Error("[ActivateOrderLogic] 处理订单失败,将重试",
|
logger.WithContext(ctx).Error("[ActivateOrderLogic] 处理订单失败,将重试",
|
||||||
logger.Field("order_no", orderInfo.OrderNo),
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
logger.Field("order_type", orderInfo.Type),
|
logger.Field("order_type", orderInfo.Type),
|
||||||
@ -115,12 +124,21 @@ func (l *ActivateOrderLogic) ProcessTask(ctx context.Context, task *asynq.Task)
|
|||||||
return err // 返回 err 允许 asynq 重试
|
return err // 返回 err 允许 asynq 重试
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if err = l.reconcilePostOrderSubscriptions(ctx, orderInfo); err != nil {
|
||||||
|
l.releaseClaim(ctx, orderInfo.OrderNo)
|
||||||
|
logger.WithContext(ctx).Error("[ActivateOrderLogic] 订单订阅兜底合并失败,将重试",
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
logger.Field("order_type", orderInfo.Type),
|
||||||
|
logger.Field("error", err.Error()))
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
l.finalizeCouponAndOrder(ctx, orderInfo)
|
l.finalizeCouponAndOrder(ctx, orderInfo)
|
||||||
|
|
||||||
logger.WithContext(ctx).Info("[ActivateOrderLogic] 订单激活成功",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_finished",
|
||||||
logger.Field("order_no", orderInfo.OrderNo),
|
"[SubscriptionFlow] order activation completed",
|
||||||
logger.Field("order_type", orderInfo.Type),
|
commonLogic.OrderTraceFields(orderInfo)...,
|
||||||
logger.Field("user_id", orderInfo.UserId))
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,10 +155,11 @@ func (l *ActivateOrderLogic) parsePayload(ctx context.Context, payload []byte) (
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// validateAndGetOrder retrieves an order by order number and validates its status
|
// claimAndGetOrder retrieves an order by order number and atomically claims paid orders.
|
||||||
// Returns error if order is not found or not in paid status
|
// Returns error if order is not found or not in paid status
|
||||||
func (l *ActivateOrderLogic) validateAndGetOrder(ctx context.Context, orderNo string) (*order.Order, error) {
|
func (l *ActivateOrderLogic) claimAndGetOrder(ctx context.Context, orderNo string) (*order.Order, error) {
|
||||||
orderInfo, err := l.svc.OrderModel.FindOneByOrderNo(ctx, orderNo)
|
var orderInfo order.Order
|
||||||
|
err := l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("order_no = ?", orderNo).First(&orderInfo).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
logger.WithContext(ctx).Error("Find order failed",
|
logger.WithContext(ctx).Error("Find order failed",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@ -165,7 +184,33 @@ func (l *ActivateOrderLogic) validateAndGetOrder(ctx context.Context, orderNo st
|
|||||||
return nil, ErrInvalidOrderStatus
|
return nil, ErrInvalidOrderStatus
|
||||||
}
|
}
|
||||||
|
|
||||||
return orderInfo, nil
|
result := l.svc.DB.WithContext(ctx).
|
||||||
|
Model(&order.Order{}).
|
||||||
|
Where("order_no = ? AND status = ?", orderNo, OrderStatusPaid).
|
||||||
|
Update("status", OrderStatusClaimed)
|
||||||
|
if result.Error != nil {
|
||||||
|
return nil, result.Error
|
||||||
|
}
|
||||||
|
if result.RowsAffected == 0 {
|
||||||
|
logger.WithContext(ctx).Info("Order already claimed by another worker, skip processing",
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
orderInfo.Status = OrderStatusClaimed
|
||||||
|
return &orderInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ActivateOrderLogic) releaseClaim(ctx context.Context, orderNo string) {
|
||||||
|
if err := l.svc.DB.WithContext(ctx).
|
||||||
|
Model(&order.Order{}).
|
||||||
|
Where("order_no = ? AND status = ?", orderNo, OrderStatusClaimed).
|
||||||
|
Update("status", OrderStatusPaid).Error; err != nil {
|
||||||
|
logger.WithContext(ctx).Error("Release order claim failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("order_no", orderNo),
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// processOrderByType routes order processing based on the order type
|
// processOrderByType routes order processing based on the order type
|
||||||
@ -187,6 +232,311 @@ func (l *ActivateOrderLogic) processOrderByType(ctx context.Context, orderInfo *
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (l *ActivateOrderLogic) reconcilePostOrderSubscriptions(ctx context.Context, orderInfo *order.Order) error {
|
||||||
|
if !shouldReconcilePostOrderSubscriptions(orderInfo) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
effectiveUserID := orderInfo.UserId
|
||||||
|
if orderInfo.SubscriptionUserId > 0 {
|
||||||
|
effectiveUserID = orderInfo.SubscriptionUserId
|
||||||
|
}
|
||||||
|
if effectiveUserID == 0 || orderInfo.Id == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
survivor user.Subscribe
|
||||||
|
survivorBefore user.Subscribe
|
||||||
|
losers []user.Subscribe
|
||||||
|
mergedIDs []int64
|
||||||
|
subscribeIDsToClear = make(map[int64]struct{})
|
||||||
|
missingSurvivor bool
|
||||||
|
ownerMismatchSkipped bool
|
||||||
|
identitySourceID int64
|
||||||
|
)
|
||||||
|
|
||||||
|
err := l.svc.DB.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("order_id = ?", orderInfo.Id).
|
||||||
|
First(&survivor).Error; err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
missingSurvivor = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
survivorBefore = survivor
|
||||||
|
|
||||||
|
var ownerSubs []user.Subscribe
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id = ?", effectiveUserID).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&ownerSubs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if survivor.UserId != effectiveUserID {
|
||||||
|
if len(ownerSubs) == 0 {
|
||||||
|
ownerMismatchSkipped = true
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&user.Subscribe{}).
|
||||||
|
Where("id = ?", survivor.Id).
|
||||||
|
Update("user_id", effectiveUserID).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
survivor.UserId = effectiveUserID
|
||||||
|
|
||||||
|
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||||
|
Model(&user.Subscribe{}).
|
||||||
|
Where("user_id = ?", effectiveUserID).
|
||||||
|
Order("id ASC").
|
||||||
|
Find(&ownerSubs).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(ownerSubs) <= 1 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
maxExpire := survivor.ExpireTime
|
||||||
|
for i := range ownerSubs {
|
||||||
|
item := ownerSubs[i]
|
||||||
|
if item.Id == survivor.Id {
|
||||||
|
if item.ExpireTime.After(maxExpire) {
|
||||||
|
maxExpire = item.ExpireTime
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
losers = append(losers, item)
|
||||||
|
mergedIDs = append(mergedIDs, item.Id)
|
||||||
|
if item.ExpireTime.After(maxExpire) {
|
||||||
|
maxExpire = item.ExpireTime
|
||||||
|
}
|
||||||
|
if item.SubscribeId > 0 {
|
||||||
|
subscribeIDsToClear[item.SubscribeId] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(losers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if survivor.SubscribeId > 0 {
|
||||||
|
subscribeIDsToClear[survivor.SubscribeId] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
identitySource := pickSubscriptionIdentitySource(losers)
|
||||||
|
if identitySource != nil {
|
||||||
|
identitySourceID = identitySource.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFields := map[string]interface{}{
|
||||||
|
"status": 1,
|
||||||
|
"finished_at": nil,
|
||||||
|
}
|
||||||
|
if maxExpire.After(survivor.ExpireTime) {
|
||||||
|
survivor.ExpireTime = maxExpire
|
||||||
|
updateFields["expire_time"] = maxExpire
|
||||||
|
}
|
||||||
|
if identitySource != nil {
|
||||||
|
if identitySource.Token != "" {
|
||||||
|
survivor.Token = identitySource.Token
|
||||||
|
updateFields["token"] = identitySource.Token
|
||||||
|
}
|
||||||
|
if identitySource.UUID != "" {
|
||||||
|
survivor.UUID = identitySource.UUID
|
||||||
|
updateFields["uuid"] = identitySource.UUID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loserIDs := make([]int64, 0, len(losers))
|
||||||
|
for i := range losers {
|
||||||
|
loserIDs = append(loserIDs, losers[i].Id)
|
||||||
|
}
|
||||||
|
if len(loserIDs) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// user_subscribe 当前没有 deleted_at 字段,这里沿用项目现有删除语义清理 loser 记录。
|
||||||
|
if err := tx.Where("id IN ?", loserIDs).Delete(&user.Subscribe{}).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&user.Subscribe{}).
|
||||||
|
Where("id = ?", survivor.Id).
|
||||||
|
Updates(updateFields).Error; err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
survivor.Status = 1
|
||||||
|
survivor.FinishedAt = nil
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if missingSurvivor {
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "post_order_reconcile_skipped",
|
||||||
|
"[SubscriptionFlow] post-order reconcile skipped because survivor subscription was not found",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("effective_user_id", effectiveUserID),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if ownerMismatchSkipped {
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "post_order_reconcile_skipped",
|
||||||
|
"[SubscriptionFlow] post-order reconcile skipped because survivor owner mismatch had no duplicates",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("effective_user_id", effectiveUserID),
|
||||||
|
logger.Field("survivor_subscribe_id", survivor.Id),
|
||||||
|
logger.Field("survivor_user_id", survivorBefore.UserId),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(losers) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
l.clearPostOrderReconcileCache(ctx, &survivorBefore, &survivor, losers, subscribeIDsToClear)
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "post_order_reconciled",
|
||||||
|
"[SubscriptionFlow] post-order reconcile merged duplicate subscriptions",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("effective_user_id", effectiveUserID),
|
||||||
|
logger.Field("survivor_subscribe_id", survivor.Id),
|
||||||
|
logger.Field("identity_source_subscribe_id", identitySourceID),
|
||||||
|
logger.Field("merged_subscribe_ids", mergedIDs),
|
||||||
|
logger.Field("merged_count", len(mergedIDs)),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldReconcilePostOrderSubscriptions(orderInfo *order.Order) bool {
|
||||||
|
if orderInfo == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
switch orderInfo.Type {
|
||||||
|
case OrderTypeSubscribe, OrderTypeRenewal, OrderTypeRedemption:
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickSubscriptionIdentitySource(candidates []user.Subscribe) *user.Subscribe {
|
||||||
|
if len(candidates) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
best := &candidates[0]
|
||||||
|
for i := 1; i < len(candidates); i++ {
|
||||||
|
candidate := &candidates[i]
|
||||||
|
if subscriptionIdentityPriority(candidate, best) {
|
||||||
|
best = candidate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func subscriptionIdentityPriority(candidate *user.Subscribe, current *user.Subscribe) bool {
|
||||||
|
if candidate == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if current == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
candidateUsable := candidate.Token != "" || candidate.UUID != ""
|
||||||
|
currentUsable := current.Token != "" || current.UUID != ""
|
||||||
|
if candidateUsable != currentUsable {
|
||||||
|
return candidateUsable
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.ExpireTime.After(current.ExpireTime) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if current.ExpireTime.After(candidate.ExpireTime) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if candidate.UpdatedAt.After(current.UpdatedAt) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if current.UpdatedAt.After(candidate.UpdatedAt) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidate.Id > current.Id
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ActivateOrderLogic) clearPostOrderReconcileCache(
|
||||||
|
ctx context.Context,
|
||||||
|
survivorBefore *user.Subscribe,
|
||||||
|
survivorAfter *user.Subscribe,
|
||||||
|
losers []user.Subscribe,
|
||||||
|
subscribeIDs map[int64]struct{},
|
||||||
|
) {
|
||||||
|
cacheModels := make([]*user.Subscribe, 0, len(losers)+2)
|
||||||
|
if survivorBefore != nil {
|
||||||
|
cacheModels = append(cacheModels, survivorBefore)
|
||||||
|
}
|
||||||
|
if survivorAfter != nil {
|
||||||
|
cacheModels = append(cacheModels, survivorAfter)
|
||||||
|
}
|
||||||
|
for i := range losers {
|
||||||
|
loser := losers[i]
|
||||||
|
cacheModels = append(cacheModels, &loser)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cacheModels) > 0 {
|
||||||
|
if err := l.svc.UserModel.ClearSubscribeCache(ctx, cacheModels...); err != nil {
|
||||||
|
logger.WithContext(ctx).Error("Post-order reconcile clear subscribe cache failed",
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.svc.SubscribeModel != nil {
|
||||||
|
for subscribeID := range subscribeIDs {
|
||||||
|
if err := l.svc.SubscribeModel.ClearCache(ctx, subscribeID); err != nil {
|
||||||
|
logger.WithContext(ctx).Error("Post-order reconcile clear plan cache failed",
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("subscribe_id", subscribeID),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if l.svc.NodeModel != nil {
|
||||||
|
if err := l.svc.NodeModel.ClearServerAllCache(ctx); err != nil {
|
||||||
|
logger.WithContext(ctx).Error("Post-order reconcile clear node cache failed",
|
||||||
|
logger.Field("reason", "post_order_reconcile"),
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// finalizeCouponAndOrder handles post-processing tasks including coupon updates
|
// finalizeCouponAndOrder handles post-processing tasks including coupon updates
|
||||||
// and order status finalization
|
// and order status finalization
|
||||||
func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderInfo *order.Order) {
|
func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderInfo *order.Order) {
|
||||||
@ -208,6 +558,10 @@ func (l *ActivateOrderLogic) finalizeCouponAndOrder(ctx context.Context, orderIn
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
orderInfo.Status = OrderStatusFinished
|
orderInfo.Status = OrderStatusFinished
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "order_status_finished",
|
||||||
|
"[SubscriptionFlow] order status updated to finished",
|
||||||
|
commonLogic.OrderTraceFields(orderInfo)...,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewPurchase handles new subscription purchase including user creation,
|
// NewPurchase handles new subscription purchase including user creation,
|
||||||
@ -218,6 +572,13 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "activation_user_resolved",
|
||||||
|
"[SubscriptionFlow] activation resolved subscription recipient user",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("resolved_user_id", userInfo.Id),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
sub, err := l.getSubscribeInfo(ctx, orderInfo.SubscribeId)
|
sub, err := l.getSubscribeInfo(ctx, orderInfo.SubscribeId)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@ -258,12 +619,14 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
userSub = anchorSub
|
userSub = anchorSub
|
||||||
logger.WithContext(ctx).Infow("Single mode purchase routed to renewal in activation",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_reused",
|
||||||
logger.Field("mode", "single"),
|
"[SubscriptionFlow] activation reused single-mode anchor subscription",
|
||||||
logger.Field("route", "purchase_to_renewal"),
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
logger.Field("plan_changed", anchorSub.SubscribeId != orderInfo.SubscribeId),
|
append(commonLogic.UserSubscribeTraceFields(anchorSub),
|
||||||
logger.Field("anchor_user_subscribe_id", anchorSub.Id),
|
logger.Field("reuse_reason", "single_mode_purchase_to_renewal"),
|
||||||
logger.Field("order_no", orderInfo.OrderNo),
|
logger.Field("plan_changed", anchorSub.SubscribeId != orderInfo.SubscribeId),
|
||||||
|
)...,
|
||||||
|
)...,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
case errors.Is(anchorErr, gorm.ErrRecordNotFound):
|
||||||
@ -274,25 +637,76 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有合并已购订阅,再尝试合并赠送订阅(order_id=0)
|
}
|
||||||
if userSub == nil {
|
|
||||||
giftSub, giftErr := l.findGiftSubscription(ctx, singleModeUserId, orderInfo.SubscribeId)
|
// 如果没有合并已购订阅,再尝试合并赠送订阅(order_id=0)。
|
||||||
if giftErr == nil && giftSub != nil {
|
// 全局单订阅口径下,非 SingleModel 也不能让试用订阅和付费订阅并存。
|
||||||
// 在赠送订阅上延长时间,保持 token 不变
|
if userSub == nil {
|
||||||
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
effectiveOwner := orderInfo.UserId
|
||||||
if err != nil {
|
if orderInfo.SubscriptionUserId > 0 {
|
||||||
logger.WithContext(ctx).Error("Extend gift subscription failed",
|
effectiveOwner = orderInfo.SubscriptionUserId
|
||||||
logger.Field("error", err.Error()),
|
}
|
||||||
logger.Field("gift_subscribe_id", giftSub.Id),
|
giftSub, giftErr := l.findGiftSubscription(ctx, effectiveOwner, orderInfo.SubscribeId)
|
||||||
)
|
if giftErr == nil && giftSub != nil {
|
||||||
// 合并失败时回退到创建新订阅
|
userSub, err = l.extendGiftSubscription(ctx, giftSub, orderInfo, sub)
|
||||||
userSub = nil
|
if err != nil {
|
||||||
}
|
logger.WithContext(ctx).Error("Extend gift subscription failed",
|
||||||
|
logger.Field("error", err.Error()),
|
||||||
|
logger.Field("gift_subscribe_id", giftSub.Id),
|
||||||
|
)
|
||||||
|
userSub = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果没有合并赠送订阅,则正常创建新订阅
|
// 兜底:创建新订阅前,查找用户是否已有同套餐的订阅记录(含过期/赠送),
|
||||||
|
// 有则复用旧记录续期,避免出现重复订阅。
|
||||||
|
// 需要同时检查 UserId 和 SubscriptionUserId,因为家庭组绑定前后 owner 可能不同。
|
||||||
|
if userSub == nil {
|
||||||
|
candidateUserIds := []int64{orderInfo.UserId}
|
||||||
|
if orderInfo.SubscriptionUserId > 0 && orderInfo.SubscriptionUserId != orderInfo.UserId {
|
||||||
|
candidateUserIds = append(candidateUserIds, orderInfo.SubscriptionUserId)
|
||||||
|
}
|
||||||
|
var existingSub user.Subscribe
|
||||||
|
if findErr := l.svc.DB.Model(&user.Subscribe{}).
|
||||||
|
Where("user_id IN ? AND token != ''", candidateUserIds).
|
||||||
|
Order("expire_time DESC").
|
||||||
|
Order("updated_at DESC").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&existingSub).Error; findErr == nil {
|
||||||
|
// 家庭组场景:订阅 owner 可能变更(如成员注册的试用 → 被家主收归),
|
||||||
|
// 续期前把 user_id 校正为当前订单的 SubscriptionUserId
|
||||||
|
effectiveOwner := orderInfo.UserId
|
||||||
|
if orderInfo.SubscriptionUserId > 0 {
|
||||||
|
effectiveOwner = orderInfo.SubscriptionUserId
|
||||||
|
}
|
||||||
|
if existingSub.UserId != effectiveOwner {
|
||||||
|
existingSub.UserId = effectiveOwner
|
||||||
|
}
|
||||||
|
// 找到已有记录,走续期逻辑
|
||||||
|
if renewErr := l.updateSubscriptionForRenewal(ctx, &existingSub, sub, orderInfo); renewErr != nil {
|
||||||
|
logger.WithContext(ctx).Error("Fallback renew existing subscription failed, will create new",
|
||||||
|
logger.Field("error", renewErr.Error()),
|
||||||
|
logger.Field("existing_subscribe_id", existingSub.Id),
|
||||||
|
logger.Field("order_no", orderInfo.OrderNo),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
userSub = &existingSub
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_reused",
|
||||||
|
"[SubscriptionFlow] activation renewed an existing subscription instead of creating a duplicate",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
append(commonLogic.UserSubscribeTraceFields(&existingSub),
|
||||||
|
logger.Field("reuse_reason", "fallback_existing_subscription"),
|
||||||
|
logger.Field("candidate_user_ids", candidateUserIds),
|
||||||
|
logger.Field("owner_corrected_to", effectiveOwner),
|
||||||
|
)...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果仍然没有可复用的订阅,才创建新订阅
|
||||||
if userSub == nil {
|
if userSub == nil {
|
||||||
userSub, err = l.createUserSubscription(ctx, orderInfo, sub)
|
userSub, err = l.createUserSubscription(ctx, orderInfo, sub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -309,7 +723,12 @@ func (l *ActivateOrderLogic) NewPurchase(ctx context.Context, orderInfo *order.O
|
|||||||
// Clear cache
|
// Clear cache
|
||||||
l.clearServerCache(ctx, sub)
|
l.clearServerCache(ctx, sub)
|
||||||
|
|
||||||
logger.WithContext(ctx).Info("Insert user subscribe success")
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_issued",
|
||||||
|
"[SubscriptionFlow] activation finished issuing subscription entitlement",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
commonLogic.UserSubscribeTraceFields(userSub)...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -380,6 +799,14 @@ func (l *ActivateOrderLogic) createGuestUser(ctx context.Context, orderInfo *ord
|
|||||||
logger.Field("identifier", tempOrder.Identifier),
|
logger.Field("identifier", tempOrder.Identifier),
|
||||||
logger.Field("auth_type", tempOrder.AuthType),
|
logger.Field("auth_type", tempOrder.AuthType),
|
||||||
)
|
)
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "guest_user_created",
|
||||||
|
"[SubscriptionFlow] guest user created during order activation",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
logger.Field("created_user_id", userInfo.Id),
|
||||||
|
logger.Field("identifier", tempOrder.Identifier),
|
||||||
|
logger.Field("auth_type", tempOrder.AuthType),
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
return userInfo, nil
|
return userInfo, nil
|
||||||
}
|
}
|
||||||
@ -473,7 +900,7 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
|||||||
// Check quota limit before creating subscription (final safeguard)
|
// Check quota limit before creating subscription (final safeguard)
|
||||||
if sub.Quota > 0 {
|
if sub.Quota > 0 {
|
||||||
var count int64
|
var count int64
|
||||||
if err := l.svc.DB.Model(&user.Subscribe{}).Where("user_id = ? AND subscribe_id = ?", orderInfo.UserId, orderInfo.SubscribeId).Count(&count).Error; err != nil {
|
if err := l.svc.DB.Model(&user.Subscribe{}).Where("user_id = ?", subscriptionUserId).Count(&count).Error; err != nil {
|
||||||
logger.WithContext(ctx).Error("Count user subscribe failed", logger.Field("error", err.Error()))
|
logger.WithContext(ctx).Error("Count user subscribe failed", logger.Field("error", err.Error()))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@ -493,6 +920,13 @@ func (l *ActivateOrderLogic) createUserSubscription(ctx context.Context, orderIn
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_created",
|
||||||
|
"[SubscriptionFlow] new user subscription record created",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
commonLogic.UserSubscribeTraceFields(userSub)...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
return userSub, nil
|
return userSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -500,13 +934,13 @@ func (l *ActivateOrderLogic) patchOrderParentID(ctx context.Context, orderID int
|
|||||||
return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error
|
return l.svc.DB.WithContext(ctx).Model(&order.Order{}).Where("id = ? AND (parent_id = 0 OR parent_id IS NULL)", orderID).Update("parent_id", parentID).Error
|
||||||
}
|
}
|
||||||
|
|
||||||
// findGiftSubscription 查找用户指定套餐的赠送订阅(order_id=0),包括已过期的
|
// findGiftSubscription 查找用户的赠送订阅(order_id=0),包括已过期的。
|
||||||
// 返回找到的赠送订阅记录,如果没有则返回 nil
|
// 单订阅模式下,用户若以不同套餐首次购买,需要将赠送订阅合并为付费订阅,
|
||||||
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, subscribeId int64) (*user.Subscribe, error) {
|
// 因此不再过滤 subscribe_id,避免套餐不同时绕过合并路径创建重复订阅。
|
||||||
// 直接查询数据库,查找 order_id=0(赠送)且同套餐的订阅,不限制过期状态
|
func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId int64, _ int64) (*user.Subscribe, error) {
|
||||||
var giftSub user.Subscribe
|
var giftSub user.Subscribe
|
||||||
err := l.svc.DB.Model(&user.Subscribe{}).
|
err := l.svc.DB.Model(&user.Subscribe{}).
|
||||||
Where("user_id = ? AND order_id = 0 AND subscribe_id = ?", userId, subscribeId).
|
Where("user_id = ? AND order_id = 0", userId).
|
||||||
Order("created_at DESC").
|
Order("created_at DESC").
|
||||||
First(&giftSub).Error
|
First(&giftSub).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -515,23 +949,28 @@ func (l *ActivateOrderLogic) findGiftSubscription(ctx context.Context, userId in
|
|||||||
return &giftSub, nil
|
return &giftSub, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token 不变
|
// extendGiftSubscription 在现有赠送订阅上延长到期时间,保持 token/UUID 不变。
|
||||||
// 将购买的天数叠加到赠送订阅的到期时间上,并更新 order_id 为新订单 ID
|
// 若购买套餐与赠送套餐不同,同步更新套餐 ID 和流量配额并重置已用量(套餐变更语义)。
|
||||||
func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
|
func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub *user.Subscribe, orderInfo *order.Order, sub *subscribe.Subscribe) (*user.Subscribe, error) {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
// 计算基准时间:取赠送订阅到期时间和当前时间的较大值
|
|
||||||
baseTime := giftSub.ExpireTime
|
baseTime := giftSub.ExpireTime
|
||||||
if baseTime.Before(now) {
|
if baseTime.Before(now) {
|
||||||
baseTime = now
|
baseTime = now
|
||||||
}
|
}
|
||||||
// 在基准时间上增加购买的天数
|
|
||||||
newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime)
|
newExpireTime := tool.AddTime(sub.UnitTime, orderInfo.Quantity, baseTime)
|
||||||
|
|
||||||
// 更新赠送订阅的信息
|
|
||||||
giftSub.OrderId = orderInfo.Id
|
giftSub.OrderId = orderInfo.Id
|
||||||
giftSub.ExpireTime = newExpireTime
|
giftSub.ExpireTime = newExpireTime
|
||||||
giftSub.Status = 1
|
giftSub.Status = 1
|
||||||
|
|
||||||
|
// 套餐变更:更新套餐 ID 和流量配额,重置已用流量(与 updateSubscriptionForRenewal 逻辑一致)
|
||||||
|
if giftSub.SubscribeId != orderInfo.SubscribeId {
|
||||||
|
giftSub.SubscribeId = orderInfo.SubscribeId
|
||||||
|
giftSub.Traffic = sub.Traffic
|
||||||
|
giftSub.Download = 0
|
||||||
|
giftSub.Upload = 0
|
||||||
|
}
|
||||||
|
|
||||||
if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil {
|
if err := l.svc.UserModel.UpdateSubscribe(ctx, giftSub); err != nil {
|
||||||
logger.WithContext(ctx).Error("Update gift subscription failed",
|
logger.WithContext(ctx).Error("Update gift subscription failed",
|
||||||
logger.Field("error", err.Error()),
|
logger.Field("error", err.Error()),
|
||||||
@ -540,11 +979,15 @@ func (l *ActivateOrderLogic) extendGiftSubscription(ctx context.Context, giftSub
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.WithContext(ctx).Info("Extended gift subscription successfully",
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_reused",
|
||||||
logger.Field("subscribe_id", giftSub.Id),
|
"[SubscriptionFlow] paid order extended an existing gift subscription",
|
||||||
logger.Field("old_expire_time", baseTime),
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
logger.Field("new_expire_time", newExpireTime),
|
append(commonLogic.UserSubscribeTraceFields(giftSub),
|
||||||
logger.Field("order_id", orderInfo.Id),
|
logger.Field("reuse_reason", "gift_subscription_promoted"),
|
||||||
|
logger.Field("old_expire_time", baseTime),
|
||||||
|
logger.Field("new_expire_time", newExpireTime),
|
||||||
|
)...,
|
||||||
|
)...,
|
||||||
)
|
)
|
||||||
|
|
||||||
return giftSub, nil
|
return giftSub, nil
|
||||||
@ -556,7 +999,7 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
|||||||
if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) {
|
if !l.shouldProcessCommission(userInfo, orderInfo.IsNew) {
|
||||||
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
|
// 普通用户路径(佣金比例=0):只有首单才双方赠N天
|
||||||
if orderInfo.IsNew {
|
if orderInfo.IsNew {
|
||||||
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo.OrderNo)
|
l.grantGiftDaysToBothParties(ctx, userInfo, orderInfo)
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -643,14 +1086,21 @@ func (l *ActivateOrderLogic) handleCommission(ctx context.Context, userInfo *use
|
|||||||
logger.Field("user_id", referer.Id),
|
logger.Field("user_id", referer.Id),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 有佣金路径:邀请人拿佣金,被邀请用户(首单)拿天数
|
||||||
|
if orderInfo.IsNew {
|
||||||
|
giftTarget := l.resolveGiftTargetUser(ctx, userInfo, orderInfo.SubscriptionUserId)
|
||||||
|
_ = l.grantGiftDays(ctx, giftTarget, int(l.svc.Config.Invite.GiftDays), orderInfo.OrderNo, "邀请赠送")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderNo string) {
|
func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, referee *user.User, orderInfo *order.Order) {
|
||||||
giftDays := l.svc.Config.Invite.GiftDays
|
giftDays := l.svc.Config.Invite.GiftDays
|
||||||
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 {
|
if giftDays <= 0 || referee == nil || referee.Id == 0 || referee.RefererId == 0 || orderInfo == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = l.grantGiftDays(ctx, referee, int(giftDays), orderNo, "邀请赠送")
|
refereeTarget := l.resolveGiftTargetUser(ctx, referee, orderInfo.SubscriptionUserId)
|
||||||
|
_ = l.grantGiftDays(ctx, refereeTarget, int(giftDays), orderInfo.OrderNo, "邀请赠送")
|
||||||
if referee.RefererId == 0 {
|
if referee.RefererId == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -658,7 +1108,32 @@ func (l *ActivateOrderLogic) grantGiftDaysToBothParties(ctx context.Context, ref
|
|||||||
if err != nil || referer == nil {
|
if err != nil || referer == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_ = l.grantGiftDays(ctx, referer, int(giftDays), orderNo, "邀请赠送")
|
refererTarget := l.resolveGiftTargetUser(ctx, referer, 0)
|
||||||
|
_ = l.grantGiftDays(ctx, refererTarget, int(giftDays), orderInfo.OrderNo, "邀请赠送")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *ActivateOrderLogic) resolveGiftTargetUser(ctx context.Context, source *user.User, forcedOwnerID int64) *user.User {
|
||||||
|
if source == nil || source.Id == 0 {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
targetID := source.Id
|
||||||
|
if forcedOwnerID > 0 {
|
||||||
|
targetID = forcedOwnerID
|
||||||
|
} else if entitlement, err := commonLogic.ResolveEntitlementUser(ctx, l.svc.DB, source.Id); err == nil && entitlement != nil && entitlement.EffectiveUserID > 0 {
|
||||||
|
targetID = entitlement.EffectiveUserID
|
||||||
|
}
|
||||||
|
if targetID == source.Id {
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
target, err := l.svc.UserModel.FindOne(ctx, targetID)
|
||||||
|
if err != nil || target == nil {
|
||||||
|
logger.WithContext(ctx).Error("Resolve gift target owner failed",
|
||||||
|
logger.Field("source_user_id", source.Id),
|
||||||
|
logger.Field("target_user_id", targetID),
|
||||||
|
)
|
||||||
|
return source
|
||||||
|
}
|
||||||
|
return target
|
||||||
}
|
}
|
||||||
|
|
||||||
func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, days int, orderNo string, remark string) error {
|
func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, days int, orderNo string, remark string) error {
|
||||||
@ -685,7 +1160,22 @@ func (l *ActivateOrderLogic) grantGiftDays(ctx context.Context, u *user.User, da
|
|||||||
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id)
|
activeSubscribe, err := l.svc.UserModel.FindActiveSubscribe(ctx, u.Id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
return nil
|
giftLog := &log.Gift{
|
||||||
|
Type: log.GiftTypeIncrease,
|
||||||
|
OrderNo: orderNo,
|
||||||
|
SubscribeId: 0,
|
||||||
|
Amount: int64(days),
|
||||||
|
Balance: u.Balance,
|
||||||
|
Remark: remark + " skipped: no active subscription",
|
||||||
|
Timestamp: time.Now().UnixMilli(),
|
||||||
|
}
|
||||||
|
content, _ := giftLog.Marshal()
|
||||||
|
return l.svc.LogModel.Insert(ctx, &log.SystemLog{
|
||||||
|
Type: log.TypeGift.Uint8(),
|
||||||
|
Date: time.Now().Format("2006-01-02"),
|
||||||
|
ObjectID: u.Id,
|
||||||
|
Content: string(content),
|
||||||
|
})
|
||||||
}
|
}
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@ -849,6 +1339,9 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Trigger user group recalculation (needed when renewing an expired subscription)
|
||||||
|
l.triggerUserGroupRecalculation(ctx, userInfo.Id)
|
||||||
|
|
||||||
// Clear user subscription cache
|
// Clear user subscription cache
|
||||||
err = l.svc.UserModel.ClearSubscribeCache(ctx, userSub)
|
err = l.svc.UserModel.ClearSubscribeCache(ctx, userSub)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -865,6 +1358,15 @@ func (l *ActivateOrderLogic) Renewal(ctx context.Context, orderInfo *order.Order
|
|||||||
// Handle commission
|
// Handle commission
|
||||||
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
go l.handleCommission(context.Background(), userInfo, orderInfo)
|
||||||
|
|
||||||
|
commonLogic.SubscriptionTraceInfo(logger.WithContext(ctx), commonLogic.SubscriptionTraceFlowOrder, "subscription_renewed",
|
||||||
|
"[SubscriptionFlow] renewal order updated existing subscription",
|
||||||
|
append(commonLogic.OrderTraceFields(orderInfo),
|
||||||
|
append(commonLogic.UserSubscribeTraceFields(userSub),
|
||||||
|
logger.Field("iap_expire_at", iapExpireAt),
|
||||||
|
)...,
|
||||||
|
)...,
|
||||||
|
)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
436
queue/logic/order/activateOrderLogic_invite_test.go
Normal file
436
queue/logic/order/activateOrderLogic_invite_test.go
Normal file
@ -0,0 +1,436 @@
|
|||||||
|
package orderLogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/perfect-panel/server/internal/config"
|
||||||
|
userLogic "github.com/perfect-panel/server/internal/logic/public/user"
|
||||||
|
modelLog "github.com/perfect-panel/server/internal/model/log"
|
||||||
|
"github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/perfect-panel/server/internal/svc"
|
||||||
|
"github.com/perfect-panel/server/internal/types"
|
||||||
|
"github.com/perfect-panel/server/pkg/constant"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"gorm.io/driver/mysql"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 普通用户 + 首单 → 双方赠N天
|
||||||
|
func TestHandleCommission_GrantGiftDaysWhenCommissionDisabled_FirstOrder(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 0,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referee.RefererId = referer.Id
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||||
|
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||||
|
|
||||||
|
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||||
|
OrderNo: "ORD-GIFT-001",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: true, // 首单
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
|
||||||
|
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 2)
|
||||||
|
|
||||||
|
var giftCount int64
|
||||||
|
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||||
|
t.Fatalf("count gift logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if giftCount != 2 {
|
||||||
|
t.Fatalf("expected 2 gift logs, got %d", giftCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 普通用户 + 非首单 → 不赠送
|
||||||
|
func TestHandleCommission_NoGiftDaysWhenCommissionDisabled_NotFirstOrder(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 0,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referee.RefererId = referer.Id
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||||
|
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||||
|
|
||||||
|
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||||
|
OrderNo: "ORD-GIFT-002",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: false, // 非首单
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 到期时间不应延长
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||||
|
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
|
||||||
|
|
||||||
|
var giftCount int64
|
||||||
|
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||||
|
t.Fatalf("count gift logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if giftCount != 0 {
|
||||||
|
t.Fatalf("expected 0 gift logs for non-first order, got %d", giftCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渠道 + 首单 → 被邀请人赠N天 + 邀请人获佣金
|
||||||
|
func TestHandleCommission_GiftDaysAndCommissionWhenChannelFirstOrder(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 10,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referee.RefererId = referer.Id
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||||
|
|
||||||
|
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||||
|
OrderNo: "ORD-COMM-001",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: true, // 首单
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 被邀请人(首单)应获得赠送天数
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 2)
|
||||||
|
|
||||||
|
// 邀请人应获得佣金
|
||||||
|
var refererAfter user.User
|
||||||
|
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
|
||||||
|
t.Fatalf("query referer failed: %v", err)
|
||||||
|
}
|
||||||
|
if refererAfter.Commission != 10 {
|
||||||
|
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
|
||||||
|
}
|
||||||
|
|
||||||
|
var giftCount int64
|
||||||
|
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||||
|
t.Fatalf("count gift logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if giftCount != 1 {
|
||||||
|
t.Fatalf("expected 1 gift log for referee on first order with commission, got %d", giftCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 渠道 + 非首单 → 只给邀请人佣金,不赠天
|
||||||
|
func TestHandleCommission_OnlyCommissionWhenChannelNotFirstOrder(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 10,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referee.RefererId = referer.Id
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(96 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||||
|
|
||||||
|
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||||
|
OrderNo: "ORD-COMM-002",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: false, // 非首单
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 被邀请人不应获得赠送天数
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||||
|
|
||||||
|
// 邀请人应获得佣金
|
||||||
|
var refererAfter user.User
|
||||||
|
if err := db.First(&refererAfter, referer.Id).Error; err != nil {
|
||||||
|
t.Fatalf("query referer failed: %v", err)
|
||||||
|
}
|
||||||
|
if refererAfter.Commission != 10 {
|
||||||
|
t.Fatalf("expected referer commission=10, got %d", refererAfter.Commission)
|
||||||
|
}
|
||||||
|
|
||||||
|
var giftCount int64
|
||||||
|
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||||
|
t.Fatalf("count gift logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if giftCount != 0 {
|
||||||
|
t.Fatalf("expected 0 gift logs when channel non-first order, got %d", giftCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleCommission_NoGiftDaysWhenNoInviteRelation(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 0,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
// 没有邀请人的独立用户
|
||||||
|
loneUser := seedUser(t, db, 0, false)
|
||||||
|
// RefererId == 0,无邀请关系
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||||
|
loneSub := seedActiveSubscribe(t, db, loneUser.Id, baseExpire)
|
||||||
|
|
||||||
|
logic.handleCommission(context.Background(), loneUser, &order.Order{
|
||||||
|
OrderNo: "ORD-LONE-001",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: true,
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// 订阅到期时间不应该被延长
|
||||||
|
var subAfter user.Subscribe
|
||||||
|
if err := db.First(&subAfter, loneSub.Id).Error; err != nil {
|
||||||
|
t.Fatalf("query subscribe failed: %v", err)
|
||||||
|
}
|
||||||
|
if !subAfter.ExpireTime.Equal(baseExpire) {
|
||||||
|
t.Fatalf("expected no gift days for user without inviter, before=%v after=%v", baseExpire, subAfter.ExpireTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 不应产生赠天日志
|
||||||
|
var giftCount int64
|
||||||
|
if err := db.Model(&modelLog.SystemLog{}).Where("type = ?", modelLog.TypeGift.Uint8()).Count(&giftCount).Error; err != nil {
|
||||||
|
t.Fatalf("count gift logs failed: %v", err)
|
||||||
|
}
|
||||||
|
if giftCount != 0 {
|
||||||
|
t.Fatalf("expected 0 gift logs for user without inviter, got %d", giftCount)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先绑码后首单 → 双方赠N天
|
||||||
|
func TestInviteFlow_BindThenFirstOrder_GrantGiftDays(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 0,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referer.ReferCode = fmt.Sprintf("REF-%d", referer.Id)
|
||||||
|
if err := db.Model(&user.User{}).Where("id = ?", referer.Id).Update("refer_code", referer.ReferCode).Error; err != nil {
|
||||||
|
t.Fatalf("update referer code failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
refereeBaseExpire := time.Now().Add(48 * time.Hour).Truncate(time.Second)
|
||||||
|
refererBaseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, refereeBaseExpire)
|
||||||
|
refererSub := seedActiveSubscribe(t, db, referer.Id, refererBaseExpire)
|
||||||
|
|
||||||
|
ctx := context.WithValue(context.Background(), constant.CtxKeyUser, referee)
|
||||||
|
bindLogic := userLogic.NewBindInviteCodeLogic(ctx, logic.svc)
|
||||||
|
if err := bindLogic.BindInviteCode(&types.BindInviteCodeRequest{InviteCode: referer.ReferCode}); err != nil {
|
||||||
|
t.Fatalf("bind invite code failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var refereeAfterBind user.User
|
||||||
|
if err := db.First(&refereeAfterBind, referee.Id).Error; err != nil {
|
||||||
|
t.Fatalf("query referee after bind failed: %v", err)
|
||||||
|
}
|
||||||
|
if refereeAfterBind.RefererId != referer.Id {
|
||||||
|
t.Fatalf("bind invite failed, expected referer_id=%d got=%d", referer.Id, refereeAfterBind.RefererId)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首单 IsNew=true → 双方赠N天
|
||||||
|
logic.handleCommission(context.Background(), &refereeAfterBind, &order.Order{
|
||||||
|
OrderNo: "ORD-FLOW-001",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: true,
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, refereeBaseExpire, 2)
|
||||||
|
assertExpireIncreasedByDays(t, db, refererSub.Id, refererBaseExpire, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 先买订单后绑码再续费 → 不赠送(IsNew=false)
|
||||||
|
func TestInviteFlow_OrderThenBind_NoGiftDays(t *testing.T) {
|
||||||
|
logic, db, cleanup := setupInviteTestLogic(t, config.InviteConfig{
|
||||||
|
ReferralPercentage: 0,
|
||||||
|
OnlyFirstPurchase: false,
|
||||||
|
GiftDays: 2,
|
||||||
|
})
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
referee := seedUser(t, db, 0, false)
|
||||||
|
referer := seedUser(t, db, 0, false)
|
||||||
|
referee.RefererId = referer.Id
|
||||||
|
|
||||||
|
baseExpire := time.Now().Add(72 * time.Hour).Truncate(time.Second)
|
||||||
|
refereeSub := seedActiveSubscribe(t, db, referee.Id, baseExpire)
|
||||||
|
refererSub := seedActiveSubscribe(t, db, referer.Id, baseExpire)
|
||||||
|
|
||||||
|
// 先前已有订单,IsNew=false(模拟先买订单后绑码的场景)
|
||||||
|
logic.handleCommission(context.Background(), referee, &order.Order{
|
||||||
|
OrderNo: "ORD-FLOW-002",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
IsNew: false, // 已有历史订单
|
||||||
|
Amount: 100,
|
||||||
|
FeeAmount: 0,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
})
|
||||||
|
|
||||||
|
assertExpireIncreasedByDays(t, db, refereeSub.Id, baseExpire, 0)
|
||||||
|
assertExpireIncreasedByDays(t, db, refererSub.Id, baseExpire, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupInviteTestLogic(t *testing.T, inviteCfg config.InviteConfig) (*ActivateOrderLogic, *gorm.DB, func()) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
mysqlAddr := getenvDefault("TEST_MYSQL_ADDR", "127.0.0.1:3306")
|
||||||
|
mysqlUser := getenvDefault("TEST_MYSQL_USER", "root")
|
||||||
|
mysqlPassword := getenvDefault("TEST_MYSQL_PASSWORD", "rootpassword")
|
||||||
|
|
||||||
|
adminDSN := fmt.Sprintf("%s:%s@tcp(%s)/?charset=utf8mb4&parseTime=true&loc=Local&multiStatements=true", mysqlUser, mysqlPassword, mysqlAddr)
|
||||||
|
adminDB, err := gorm.Open(mysql.Open(adminDSN), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open mysql admin connection failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbName := fmt.Sprintf("ppanel_test_invite_%d", time.Now().UnixNano())
|
||||||
|
if err := adminDB.Exec(fmt.Sprintf("CREATE DATABASE `%s` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci", dbName)).Error; err != nil {
|
||||||
|
t.Fatalf("create test database failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testDSN := fmt.Sprintf("%s:%s@tcp(%s)/%s?charset=utf8mb4&parseTime=true&loc=Local", mysqlUser, mysqlPassword, mysqlAddr, dbName)
|
||||||
|
db, err := gorm.Open(mysql.Open(testDSN), &gorm.Config{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open test database failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.AutoMigrate(&user.User{}, &user.Device{}, &user.AuthMethods{}, &user.Subscribe{}, &modelLog.SystemLog{}); err != nil {
|
||||||
|
t.Fatalf("auto migrate failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
redisAddr := getenvDefault("TEST_REDIS_ADDR", "127.0.0.1:6379")
|
||||||
|
redisPassword := getenvDefault("TEST_REDIS_PASSWORD", "")
|
||||||
|
rdb := redis.NewClient(&redis.Options{
|
||||||
|
Addr: redisAddr,
|
||||||
|
Password: redisPassword,
|
||||||
|
DB: 0,
|
||||||
|
})
|
||||||
|
if err := rdb.Ping(context.Background()).Err(); err != nil {
|
||||||
|
t.Fatalf("connect redis failed: %v", err)
|
||||||
|
}
|
||||||
|
_ = rdb.FlushDB(context.Background()).Err()
|
||||||
|
|
||||||
|
svcCtx := &svc.ServiceContext{
|
||||||
|
DB: db,
|
||||||
|
Redis: rdb,
|
||||||
|
UserModel: user.NewModel(db, rdb),
|
||||||
|
LogModel: modelLog.NewModel(db),
|
||||||
|
Config: config.Config{
|
||||||
|
Invite: inviteCfg,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
return NewActivateOrderLogic(svcCtx), db, func() {
|
||||||
|
_ = rdb.Close()
|
||||||
|
sqlDB, _ := db.DB()
|
||||||
|
if sqlDB != nil {
|
||||||
|
_ = sqlDB.Close()
|
||||||
|
}
|
||||||
|
_ = adminDB.Exec(fmt.Sprintf("DROP DATABASE IF EXISTS `%s`", dbName)).Error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedUser(t *testing.T, db *gorm.DB, referralPercentage uint8, onlyFirstPurchase bool) *user.User {
|
||||||
|
t.Helper()
|
||||||
|
u := &user.User{
|
||||||
|
Password: "pwd",
|
||||||
|
Algo: "default",
|
||||||
|
ReferralPercentage: referralPercentage,
|
||||||
|
OnlyFirstPurchase: boolPtr(onlyFirstPurchase),
|
||||||
|
Enable: boolPtr(true),
|
||||||
|
IsAdmin: boolPtr(false),
|
||||||
|
EnableBalanceNotify: boolPtr(false),
|
||||||
|
EnableLoginNotify: boolPtr(false),
|
||||||
|
EnableSubscribeNotify: boolPtr(false),
|
||||||
|
EnableTradeNotify: boolPtr(false),
|
||||||
|
}
|
||||||
|
if err := db.Create(u).Error; err != nil {
|
||||||
|
t.Fatalf("seed user failed: %v", err)
|
||||||
|
}
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedActiveSubscribe(t *testing.T, db *gorm.DB, userID int64, expireAt time.Time) *user.Subscribe {
|
||||||
|
t.Helper()
|
||||||
|
sub := &user.Subscribe{
|
||||||
|
UserId: userID,
|
||||||
|
OrderId: 1,
|
||||||
|
SubscribeId: 1,
|
||||||
|
StartTime: time.Now().Add(-24 * time.Hour),
|
||||||
|
ExpireTime: expireAt,
|
||||||
|
Traffic: 1024,
|
||||||
|
Token: fmt.Sprintf("token-%d-%d", userID, time.Now().UnixNano()),
|
||||||
|
UUID: fmt.Sprintf("uuid-%d-%d", userID, time.Now().UnixNano()),
|
||||||
|
Status: 1,
|
||||||
|
}
|
||||||
|
if err := db.Create(sub).Error; err != nil {
|
||||||
|
t.Fatalf("seed subscribe failed: %v", err)
|
||||||
|
}
|
||||||
|
return sub
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertExpireIncreasedByDays(t *testing.T, db *gorm.DB, subscribeID int64, before time.Time, days int) {
|
||||||
|
t.Helper()
|
||||||
|
var after user.Subscribe
|
||||||
|
if err := db.First(&after, subscribeID).Error; err != nil {
|
||||||
|
t.Fatalf("query subscribe failed: %v", err)
|
||||||
|
}
|
||||||
|
expected := before.Add(time.Duration(days) * 24 * time.Hour)
|
||||||
|
if !after.ExpireTime.Equal(expected) {
|
||||||
|
t.Fatalf("expire time mismatch, expected=%v got=%v", expected, after.ExpireTime)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolPtr(v bool) *bool {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func getenvDefault(key, fallback string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
199
queue/logic/order/activateOrderLogic_newUserEligibility_test.go
Normal file
199
queue/logic/order/activateOrderLogic_newUserEligibility_test.go
Normal file
@ -0,0 +1,199 @@
|
|||||||
|
package orderLogic
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
modelOrder "github.com/perfect-panel/server/internal/model/order"
|
||||||
|
"github.com/perfect-panel/server/internal/model/subscribe"
|
||||||
|
"github.com/perfect-panel/server/internal/model/user"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"gorm.io/driver/sqlite"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
"gorm.io/gorm/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupActivationEligibilityDB(t *testing.T) *gorm.DB {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{
|
||||||
|
Logger: logger.Default.LogMode(logger.Silent),
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
sqls := []string{
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
password VARCHAR(100) NOT NULL DEFAULT '',
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_device" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
identifier VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_family" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
owner_user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "user_family_member" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
family_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
role TINYINT NOT NULL DEFAULT 0,
|
||||||
|
status TINYINT NOT NULL DEFAULT 0,
|
||||||
|
join_source VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
deleted_at DATETIME DEFAULT NULL
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS "order" (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
order_no VARCHAR(255) NOT NULL DEFAULT '' UNIQUE,
|
||||||
|
type TINYINT NOT NULL DEFAULT 1,
|
||||||
|
status TINYINT NOT NULL DEFAULT 1,
|
||||||
|
subscribe_id INTEGER NOT NULL DEFAULT 0,
|
||||||
|
quantity INTEGER NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME,
|
||||||
|
updated_at DATETIME
|
||||||
|
)`,
|
||||||
|
}
|
||||||
|
for _, sql := range sqls {
|
||||||
|
require.NoError(t, db.Exec(sql).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
return db
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertActivationUser(t *testing.T, db *gorm.DB, userID int64, createdAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, db.Exec(
|
||||||
|
`INSERT INTO "user" (id, created_at, updated_at) VALUES (?, ?, datetime('now'))`,
|
||||||
|
userID,
|
||||||
|
createdAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertActivationDevice(t *testing.T, db *gorm.DB, userID int64, identifier string, createdAt time.Time) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, db.Exec(
|
||||||
|
`INSERT INTO "user_device" (user_id, identifier, created_at, updated_at) VALUES (?, ?, ?, datetime('now'))`,
|
||||||
|
userID,
|
||||||
|
identifier,
|
||||||
|
createdAt.UTC().Format("2006-01-02 15:04:05"),
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertActivationFamily(t *testing.T, db *gorm.DB, familyID, ownerUserID int64) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, db.Exec(
|
||||||
|
`INSERT INTO "user_family" (id, owner_user_id, status) VALUES (?, ?, 1)`,
|
||||||
|
familyID,
|
||||||
|
ownerUserID,
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertActivationFamilyMember(t *testing.T, db *gorm.DB, familyID, userID int64, role, status uint8, joinSource string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, db.Exec(
|
||||||
|
`INSERT INTO "user_family_member" (family_id, user_id, role, status, join_source) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
familyID,
|
||||||
|
userID,
|
||||||
|
role,
|
||||||
|
status,
|
||||||
|
joinSource,
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertActivationOrder(t *testing.T, db *gorm.DB, orderNo string, userID, subscribeID int64, status uint8) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, db.Exec(
|
||||||
|
`INSERT INTO "order" (user_id, order_no, type, status, subscribe_id, quantity, created_at, updated_at)
|
||||||
|
VALUES (?, ?, 1, ?, ?, 1, datetime('now'), datetime('now'))`,
|
||||||
|
userID,
|
||||||
|
orderNo,
|
||||||
|
status,
|
||||||
|
subscribeID,
|
||||||
|
).Error)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNewUserOnlyEligibilityAtActivation_UsesEarliestBoundDeviceTime(t *testing.T) {
|
||||||
|
db := setupActivationEligibilityDB(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ownerUserID = int64(1)
|
||||||
|
memberUserID = int64(2)
|
||||||
|
familyID = int64(10)
|
||||||
|
subscribeID = int64(100)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertActivationUser(t, db, memberUserID, time.Now().Add(-72*time.Hour))
|
||||||
|
insertActivationDevice(t, db, memberUserID, "activation-old-device", time.Now().Add(-72*time.Hour))
|
||||||
|
insertActivationFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||||
|
|
||||||
|
err := validateNewUserOnlyEligibilityAtActivation(
|
||||||
|
context.Background(),
|
||||||
|
db,
|
||||||
|
&modelOrder.Order{
|
||||||
|
UserId: ownerUserID,
|
||||||
|
OrderNo: "activation-check-old-device",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
Quantity: 1,
|
||||||
|
SubscribeId: subscribeID,
|
||||||
|
},
|
||||||
|
&subscribe.Subscribe{
|
||||||
|
Id: subscribeID,
|
||||||
|
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "is not a new user")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestValidateNewUserOnlyEligibilityAtActivation_SharesHistoryAcrossBoundScope(t *testing.T) {
|
||||||
|
db := setupActivationEligibilityDB(t)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ownerUserID = int64(11)
|
||||||
|
memberUserID = int64(12)
|
||||||
|
familyID = int64(20)
|
||||||
|
subscribeID = int64(200)
|
||||||
|
)
|
||||||
|
|
||||||
|
insertActivationUser(t, db, ownerUserID, time.Now().Add(-1*time.Hour))
|
||||||
|
insertActivationUser(t, db, memberUserID, time.Now().Add(-2*time.Hour))
|
||||||
|
insertActivationDevice(t, db, memberUserID, "activation-shared-device", time.Now().Add(-2*time.Hour))
|
||||||
|
insertActivationFamily(t, db, familyID, ownerUserID)
|
||||||
|
insertActivationFamilyMember(t, db, familyID, ownerUserID, user.FamilyRoleOwner, user.FamilyMemberActive, "owner_init")
|
||||||
|
insertActivationFamilyMember(t, db, familyID, memberUserID, user.FamilyRoleMember, user.FamilyMemberActive, "bind_email_with_verification")
|
||||||
|
insertActivationOrder(t, db, "previous-finished-order", memberUserID, subscribeID, OrderStatusFinished)
|
||||||
|
|
||||||
|
err := validateNewUserOnlyEligibilityAtActivation(
|
||||||
|
context.Background(),
|
||||||
|
db,
|
||||||
|
&modelOrder.Order{
|
||||||
|
UserId: ownerUserID,
|
||||||
|
OrderNo: "current-paid-order",
|
||||||
|
Type: OrderTypeSubscribe,
|
||||||
|
Quantity: 1,
|
||||||
|
SubscribeId: subscribeID,
|
||||||
|
},
|
||||||
|
&subscribe.Subscribe{
|
||||||
|
Id: subscribeID,
|
||||||
|
Discount: `[{"quantity":1,"discount":90,"new_user_only":true}]`,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
require.Error(t, err)
|
||||||
|
require.Contains(t, err.Error(), "already activated")
|
||||||
|
}
|
||||||
@ -43,7 +43,7 @@ func validateNewUserOnlyEligibilityAtActivation(
|
|||||||
ctx,
|
ctx,
|
||||||
db,
|
db,
|
||||||
eligibility.ScopeUserIDs,
|
eligibility.ScopeUserIDs,
|
||||||
orderInfo.SubscribeId,
|
0,
|
||||||
[]int64{OrderStatusFinished},
|
[]int64{OrderStatusFinished},
|
||||||
orderInfo.OrderNo,
|
orderInfo.OrderNo,
|
||||||
)
|
)
|
||||||
@ -51,7 +51,7 @@ func validateNewUserOnlyEligibilityAtActivation(
|
|||||||
return fmt.Errorf("new user only: check history error: %w", err)
|
return fmt.Errorf("new user only: check history error: %w", err)
|
||||||
}
|
}
|
||||||
if historyCount >= 1 {
|
if historyCount >= 1 {
|
||||||
return fmt.Errorf("new user only: user %d already activated subscribe %d", orderInfo.UserId, orderInfo.SubscribeId)
|
return fmt.Errorf("new user only: user %d already activated an order", orderInfo.UserId)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
185
scripts/debug_device_login.go
Normal file
185
scripts/debug_device_login.go
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
//go:build ignore
|
||||||
|
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/hex"
|
||||||
|
"encoding/json"
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/forgoer/openssl"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== AES 加解密(与 pkg/aes/aes.go 一致)=====
|
||||||
|
|
||||||
|
func generateKey(key string) []byte {
|
||||||
|
hash := sha256.Sum256([]byte(key))
|
||||||
|
return hash[:32]
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateIv(iv, key string) []byte {
|
||||||
|
h := md5.New()
|
||||||
|
h.Write([]byte(iv))
|
||||||
|
return generateKey(hex.EncodeToString(h.Sum(nil)) + key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesEncrypt(plainText []byte, keyStr string) (string, string, error) {
|
||||||
|
nonce := fmt.Sprintf("%x", time.Now().UnixNano())
|
||||||
|
key := generateKey(keyStr)
|
||||||
|
iv := generateIv(nonce, keyStr)
|
||||||
|
dst, err := openssl.AesCBCEncrypt(plainText, key, iv, openssl.PKCS7_PADDING)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
return base64.StdEncoding.EncodeToString(dst), nonce, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aesDecrypt(cipherText, keyStr, ivStr string) (string, error) {
|
||||||
|
decode, err := base64.StdEncoding.DecodeString(cipherText)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
key := generateKey(keyStr)
|
||||||
|
iv := generateIv(ivStr, keyStr)
|
||||||
|
dst, err := openssl.AesCBCDecrypt(decode, key, iv, openssl.PKCS7_PADDING)
|
||||||
|
return string(dst), err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 主逻辑 =====
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
deviceID := flag.String("id", "", "设备 ID (identifier)")
|
||||||
|
secret := flag.String("secret", "", "security_secret (device.security_secret)")
|
||||||
|
host := flag.String("host", "https://api.hifast.biz", "API 地址")
|
||||||
|
flag.Parse()
|
||||||
|
|
||||||
|
if *deviceID == "" || *secret == "" {
|
||||||
|
fmt.Println("用法: go run scripts/debug_device_login.go -id <设备ID> -secret <security_secret>")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. 构造登录请求体
|
||||||
|
loginBody := map[string]interface{}{
|
||||||
|
"identifier": *deviceID,
|
||||||
|
"user_agent": "DebugScript/1.0",
|
||||||
|
}
|
||||||
|
loginJSON, _ := json.Marshal(loginBody)
|
||||||
|
|
||||||
|
// 2. AES 加密请求体
|
||||||
|
encData, nonce, err := aesEncrypt(loginJSON, *secret)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ 加密失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
encBody := map[string]interface{}{
|
||||||
|
"data": encData,
|
||||||
|
"time": nonce,
|
||||||
|
}
|
||||||
|
encBodyJSON, _ := json.Marshal(encBody)
|
||||||
|
|
||||||
|
fmt.Printf("📤 登录请求体(加密): %s\n\n", encBodyJSON)
|
||||||
|
|
||||||
|
// 3. 发起设备登录请求
|
||||||
|
loginURL := *host + "/v1/auth/login/device"
|
||||||
|
req, _ := http.NewRequest("POST", loginURL, bytes.NewReader(encBodyJSON))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Login-Type", "device")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ 登录请求失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
fmt.Printf("📥 登录响应(原始): %s\n\n", respBody)
|
||||||
|
|
||||||
|
// 4. 解密响应
|
||||||
|
var respMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(respBody, &respMap); err != nil {
|
||||||
|
fmt.Printf("❌ 解析响应 JSON 失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var token string
|
||||||
|
if dataField, ok := respMap["data"]; ok {
|
||||||
|
switch d := dataField.(type) {
|
||||||
|
case map[string]interface{}:
|
||||||
|
// 加密响应
|
||||||
|
encResp, _ := d["data"].(string)
|
||||||
|
ivResp, _ := d["time"].(string)
|
||||||
|
if encResp != "" && ivResp != "" {
|
||||||
|
decrypted, err := aesDecrypt(encResp, *secret, ivResp)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ 解密响应失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("📥 登录响应(解密): %s\n\n", decrypted)
|
||||||
|
var loginData map[string]interface{}
|
||||||
|
if err := json.Unmarshal([]byte(decrypted), &loginData); err == nil {
|
||||||
|
token, _ = loginData["token"].(string)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case string:
|
||||||
|
// 未加密直接是 token 字符串
|
||||||
|
token = d
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if token == "" {
|
||||||
|
fmt.Println("❌ 未获取到 token,登录失败")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Printf("✅ Token: %s\n\n", token)
|
||||||
|
|
||||||
|
// 5. 查询订阅
|
||||||
|
subURL := *host + "/v1/public/user/subscribe"
|
||||||
|
subReq, _ := http.NewRequest("GET", subURL, nil)
|
||||||
|
subReq.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
subReq.Header.Set("Login-Type", "device")
|
||||||
|
subReq.Header.Set("X-App-Id", "debug")
|
||||||
|
|
||||||
|
subResp, err := client.Do(subReq)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ 查询订阅失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer subResp.Body.Close()
|
||||||
|
|
||||||
|
subBody, _ := io.ReadAll(subResp.Body)
|
||||||
|
fmt.Printf("📥 订阅响应(原始): %s\n\n", subBody)
|
||||||
|
|
||||||
|
// 6. 解密订阅响应
|
||||||
|
var subRespMap map[string]interface{}
|
||||||
|
if err := json.Unmarshal(subBody, &subRespMap); err == nil {
|
||||||
|
if dataField, ok := subRespMap["data"]; ok {
|
||||||
|
if d, ok := dataField.(map[string]interface{}); ok {
|
||||||
|
encResp, _ := d["data"].(string)
|
||||||
|
ivResp, _ := d["time"].(string)
|
||||||
|
if encResp != "" && ivResp != "" {
|
||||||
|
decrypted, err := aesDecrypt(encResp, *secret, ivResp)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf("❌ 解密订阅响应失败: %v\n", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 格式化输出
|
||||||
|
var pretty interface{}
|
||||||
|
json.Unmarshal([]byte(decrypted), &pretty)
|
||||||
|
out, _ := json.MarshalIndent(pretty, "", " ")
|
||||||
|
fmt.Printf("📋 订阅信息(解密):\n%s\n", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
197
scripts/diagnose_business_bugs.go
Normal file
197
scripts/diagnose_business_bugs.go
Normal 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
scripts/merge_duplicate_subscriptions.go
Normal file
204
scripts/merge_duplicate_subscriptions.go
Normal 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
scripts/replay_business_bugs.go
Normal file
787
scripts/replay_business_bugs.go
Normal 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)
|
||||||
|
}
|
||||||
1
说明文档.md
1
说明文档.md
@ -16,6 +16,7 @@
|
|||||||
| 2026-03-12 | 分析并确认 Unknown column 错误 | [x] 已完成 | 确认为 `user_device` 缺少 `short_code` 字段,已提供 SQL |
|
| 2026-03-12 | 分析并确认 Unknown column 错误 | [x] 已完成 | 确认为 `user_device` 缺少 `short_code` 字段,已提供 SQL |
|
||||||
| 2026-03-12 | 提供 SSL 证书替换指令 | [x] 已完成 | 已提供备份与替换证书的组合指令 |
|
| 2026-03-12 | 提供 SSL 证书替换指令 | [x] 已完成 | 已提供备份与替换证书的组合指令 |
|
||||||
| 2026-03-17 | 合并 internal 到 internal/main | [x] 已完成 | 已查验均为fast-forward,受限网络/权限,需手动push完成合并 |
|
| 2026-03-17 | 合并 internal 到 internal/main | [x] 已完成 | 已查验均为fast-forward,受限网络/权限,需手动push完成合并 |
|
||||||
|
| 2026-04-14 | 排查支付成功但订阅未下发问题 | [x] 已完成 | 已提供 Docker 相关的日志排查与数据库核对命令 |
|
||||||
|
|
||||||
certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airoport.win" -d hifastapp.com
|
certbot certonly --manual --preferred-challenges dns -d airoport.win -d "*.airoport.win" -d hifastapp.com
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user