增加范围以及首充版本识别

This commit is contained in:
hy001 2026-05-25 23:20:32 +08:00
parent ee5b4f5eaf
commit f1b781e3be
6 changed files with 323 additions and 23 deletions

View File

@ -235,14 +235,15 @@ type OfficialNoticeCustomizeRequest struct {
} }
type RewardGroupItem struct { type RewardGroupItem struct {
ID Int64Value `json:"id"` ID Int64Value `json:"id"`
Type string `json:"type"` Type string `json:"type"`
Name string `json:"name"` DetailType string `json:"detailType"`
Content string `json:"content"` Name string `json:"name"`
Quantity Int64Value `json:"quantity"` Content string `json:"content"`
Cover string `json:"cover"` Quantity Int64Value `json:"quantity"`
SourceURL string `json:"sourceUrl"` Cover string `json:"cover"`
Remark string `json:"remark"` SourceURL string `json:"sourceUrl"`
Remark string `json:"remark"`
} }
type RewardGroupDetail struct { type RewardGroupDetail struct {

View File

@ -41,6 +41,9 @@ func (s *Service) ProcessRechargeEvent(ctx context.Context, event RechargeEvent)
} }
return nil, err return nil, err
} }
if !isFirstRechargeRewardAppVersionSupported(normalized.AppVersion) {
return &ProcessRechargeResponse{Processed: false, Reason: "app_version_not_supported"}, nil
}
if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) { if !isAcceptedPaymentMethod(normalized.PaymentMethod, normalized.PayPlatform) {
return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil return &ProcessRechargeResponse{Processed: false, Reason: "unsupported_payment_method"}, nil
} }
@ -115,6 +118,7 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform)) payPlatform := strings.ToUpper(strings.TrimSpace(event.PayPlatform))
paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod)) paymentMethod := strings.ToUpper(strings.TrimSpace(event.PaymentMethod))
appVersion := normalizeRechargeAppVersion(event)
sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String()) sourceOrderID := firstNonEmpty(event.SourceOrderID.String(), event.OrderID.String())
eventID := strings.TrimSpace(event.EventID) eventID := strings.TrimSpace(event.EventID)
if eventID == "" && sourceOrderID != "" { if eventID == "" && sourceOrderID != "" {
@ -135,6 +139,7 @@ func (s *Service) normalizeRechargeEvent(event RechargeEvent) (normalizedRecharg
Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"), Currency: firstNonEmpty(strings.ToUpper(strings.TrimSpace(event.Currency)), "USD"),
PayPlatform: payPlatform, PayPlatform: payPlatform,
PaymentMethod: paymentMethod, PaymentMethod: paymentMethod,
AppVersion: appVersion,
SourceOrderID: sourceOrderID, SourceOrderID: sourceOrderID,
OccurredAt: occurredAt, OccurredAt: occurredAt,
}, nil }, nil
@ -357,6 +362,34 @@ func isAcceptedPaymentMethod(paymentMethod string, _ string) bool {
return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty return paymentMethod == paymentMethodGoogle || paymentMethod == paymentMethodThirdParty
} }
func normalizeRechargeAppVersion(event RechargeEvent) string {
return firstNonEmpty(
event.AppVersion.String(),
event.ClientVersion.String(),
event.Version.String(),
event.ReqVersion.String(),
rechargePayloadString(event.Payload, "appVersion", "clientVersion", "version", "reqVersion"),
)
}
func rechargePayloadString(payload json.RawMessage, keys ...string) string {
if len(payload) == 0 {
return ""
}
var values map[string]any
if err := json.Unmarshal(payload, &values); err != nil {
return ""
}
for _, key := range keys {
if value, ok := values[key]; ok {
if text := strings.TrimSpace(fmt.Sprint(value)); text != "" && text != "<nil>" {
return text
}
}
}
return ""
}
func firstPositiveAmountCents(values ...string) int64 { func firstPositiveAmountCents(values ...string) int64 {
for _, value := range values { for _, value := range values {
cents, err := parseAmountCents(value) cents, err := parseAmountCents(value)

View File

@ -120,7 +120,7 @@ func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
for _, item := range detail.RewardConfigList { for _, item := range detail.RewardConfigList {
items = append(items, RewardItem{ items = append(items, RewardItem{
ID: int64(item.ID), ID: int64(item.ID),
Type: strings.TrimSpace(item.Type), Type: rewardItemDisplayType(item),
Name: strings.TrimSpace(item.Name), Name: strings.TrimSpace(item.Name),
Content: strings.TrimSpace(item.Content), Content: strings.TrimSpace(item.Content),
Quantity: int64(item.Quantity), Quantity: int64(item.Quantity),
@ -131,6 +131,29 @@ func rewardItemsFromGroup(detail integration.RewardGroupDetail) []RewardItem {
return items return items
} }
func rewardItemDisplayType(item integration.RewardGroupItem) string {
itemType := strings.ToUpper(strings.TrimSpace(item.Type))
detailType := strings.ToUpper(strings.TrimSpace(item.DetailType))
if itemType == "PROPS" {
if detailType != "" && detailType != "PROPS" {
return detailType
}
if isVipRewardItem(item) {
return "NOBLE_VIP"
}
}
return itemType
}
func isVipRewardItem(item integration.RewardGroupItem) bool {
name := strings.ToUpper(strings.TrimSpace(item.Name))
remark := strings.ToUpper(strings.TrimSpace(item.Remark))
return strings.Contains(name, "VIP") ||
strings.Contains(name, "NOBLE") ||
strings.Contains(remark, "VIP") ||
strings.Contains(remark, "NOBLE")
}
func formatDateTime(t time.Time) string { func formatDateTime(t time.Time) string {
if t.IsZero() { if t.IsZero() {
return "" return ""

View File

@ -50,14 +50,15 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
Name: "VIP礼包", Name: "VIP礼包",
RewardConfigList: []integration.RewardGroupItem{ RewardConfigList: []integration.RewardGroupItem{
{ {
ID: integration.Int64Value(11), ID: integration.Int64Value(11),
Type: "PROPS", Type: "PROPS",
Name: "VIP 1", DetailType: "NOBLE_VIP",
Content: "2049402425177018400", Name: "VIP 1",
Quantity: integration.Int64Value(30), Content: "2049402425177018400",
Cover: "", Quantity: integration.Int64Value(30),
SourceURL: "https://example.com/vip-short-badge.png", Cover: "",
Remark: "30 天", SourceURL: "https://example.com/vip-short-badge.png",
Remark: "30 天",
}, },
}, },
} }
@ -78,6 +79,59 @@ func TestSaveConfigReturnsVipCoverFromSourceURL(t *testing.T) {
if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" { if got := resp.LevelConfigs[0].RewardItems[0].Cover; got != "https://example.com/vip-short-badge.png" {
t.Fatalf("vip cover = %q", got) t.Fatalf("vip cover = %q", got)
} }
if got := resp.LevelConfigs[0].RewardItems[0].Type; got != "NOBLE_VIP" {
t.Fatalf("vip type = %q", got)
}
}
func TestRewardItemsReturnConcretePropsType(t *testing.T) {
service, gateway, _ := newTestService(t)
gateway.groups[3001] = integration.RewardGroupDetail{
ID: integration.Int64Value(3001),
Name: "道具礼包",
RewardConfigList: []integration.RewardGroupItem{
{
ID: integration.Int64Value(21),
Type: "PROPS",
DetailType: "RIDE",
Name: "Luxury sports car",
Content: "2044620024357908482",
Quantity: integration.Int64Value(7),
Cover: "https://example.com/ride.png",
Remark: "Luxury sports car",
},
{
ID: integration.Int64Value(22),
Type: "PROPS",
Name: "VIP 3",
Content: "2050200000000000003",
Quantity: integration.Int64Value(3),
Cover: "https://example.com/vip3.png",
Remark: "VIP 3",
},
},
}
resp, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"levelConfigs": [
{"level": 1, "rechargeAmount": "9.99", "rewardGroupId": "3001", "rewardGroupName": "道具礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
items := resp.LevelConfigs[0].RewardItems
if len(items) != 2 {
t.Fatalf("reward items = %+v", items)
}
if items[0].Type != "RIDE" {
t.Fatalf("ride type = %q", items[0].Type)
}
if items[1].Type != "NOBLE_VIP" {
t.Fatalf("vip fallback type = %q", items[1].Type)
}
} }
func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) { func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
@ -108,6 +162,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999", "amountCents": "999",
"payPlatform": "APPLE", "payPlatform": "APPLE",
"paymentMethod": "APPLE", "paymentMethod": "APPLE",
"appVersion": "1.5.0",
"sourceOrderId": "1" "sourceOrderId": "1"
}`)) }`))
if err != nil { if err != nil {
@ -124,6 +179,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "499", "amountCents": "499",
"payPlatform": "GOOGLE", "payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE", "paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "LOW" "sourceOrderId": "LOW"
}`)) }`))
if err != nil { if err != nil {
@ -140,6 +196,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999", "amountCents": "999",
"payPlatform": "GOOGLE", "payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE", "paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK", "sourceOrderId": "OK",
"occurredAt": "2026-05-21T12:00:00Z" "occurredAt": "2026-05-21T12:00:00Z"
}`)) }`))
@ -166,6 +223,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999", "amountCents": "999",
"payPlatform": "GOOGLE", "payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE", "paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "OK" "sourceOrderId": "OK"
}`)) }`))
if err != nil { if err != nil {
@ -185,6 +243,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999", "amountCents": "999",
"payPlatform": "CUSTOM_PAY", "payPlatform": "CUSTOM_PAY",
"paymentMethod": "THIRD_PARTY", "paymentMethod": "THIRD_PARTY",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM" "sourceOrderId": "CUSTOM"
}`)) }`))
if err != nil { if err != nil {
@ -204,6 +263,7 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
"amountCents": "999", "amountCents": "999",
"payPlatform": "CUSTOM_GOOGLE_GATEWAY", "payPlatform": "CUSTOM_GOOGLE_GATEWAY",
"paymentMethod": "GOOGLE", "paymentMethod": "GOOGLE",
"appVersion": "1.5.1",
"sourceOrderId": "CUSTOM_GOOGLE" "sourceOrderId": "CUSTOM_GOOGLE"
}`)) }`))
if err != nil { if err != nil {
@ -225,6 +285,108 @@ func TestProcessRechargeEventFiltersAndGrantsOnce(t *testing.T) {
} }
} }
func TestProcessRechargeEventStartsCountingAtSupportedAppVersion(t *testing.T) {
service, gateway, db := newTestService(t)
gateway.groups[1001] = testRewardGroup(1001, "首冲礼包")
_, err := service.SaveConfig(context.Background(), mustDecodeSaveConfigRequest(t, `{
"sysOrigin": "LIKEI",
"enabled": true,
"levelConfigs": [
{"level": 1, "rechargeAmount": "5.00", "rewardGroupId": "1001", "rewardGroupName": "首冲礼包", "enabled": true}
]
}`))
if err != nil {
t.Fatalf("SaveConfig() error = %v", err)
}
oldVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:OLD_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.4.9",
"sourceOrderId": "OLD_VERSION"
}`))
if err != nil {
t.Fatalf("old version ProcessRechargeEvent() error = %v", err)
}
if oldVersion.Processed || oldVersion.Reason != "app_version_not_supported" {
t.Fatalf("old version response = %+v", oldVersion)
}
missingVersion, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:MISSING_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"sourceOrderId": "MISSING_VERSION"
}`))
if err != nil {
t.Fatalf("missing version ProcessRechargeEvent() error = %v", err)
}
if missingVersion.Processed || missingVersion.Reason != "app_version_not_supported" {
t.Fatalf("missing version response = %+v", missingVersion)
}
var count int64
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after unsupported versions: %v", err)
}
if count != 0 {
t.Fatalf("grant record count after unsupported versions = %d", count)
}
supported, err := service.ProcessRechargeEvent(context.Background(), mustDecodeRechargeEvent(t, `{
"eventId": "RECHARGE_SUCCESS:GOOGLE:SUPPORTED_VERSION",
"sysOrigin": "LIKEI",
"userId": "123",
"amountCents": "999",
"payPlatform": "GOOGLE",
"paymentMethod": "GOOGLE",
"appVersion": "1.5.0",
"sourceOrderId": "SUPPORTED_VERSION"
}`))
if err != nil {
t.Fatalf("supported version ProcessRechargeEvent() error = %v", err)
}
if !supported.Processed || supported.Status != statusSuccess {
t.Fatalf("supported version response = %+v", supported)
}
if err := db.Model(&model.FirstRechargeRewardGrantRecord{}).Count(&count).Error; err != nil {
t.Fatalf("count grant records after supported version: %v", err)
}
if count != 1 {
t.Fatalf("grant record count after supported version = %d", count)
}
}
func TestFirstRechargeRewardAppVersionSupported(t *testing.T) {
tests := []struct {
name string
appVersion string
want bool
}{
{name: "missing", appVersion: "", want: false},
{name: "below", appVersion: "1.4.9", want: false},
{name: "equal", appVersion: "1.5.0", want: true},
{name: "short equal", appVersion: "1.5", want: true},
{name: "above", appVersion: "1.5.1", want: true},
{name: "suffix", appVersion: "1.5.0+100", want: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isFirstRechargeRewardAppVersionSupported(tt.appVersion); got != tt.want {
t.Fatalf("isFirstRechargeRewardAppVersionSupported(%q) = %v, want %v", tt.appVersion, got, tt.want)
}
})
}
}
func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) { func newTestService(t *testing.T) (*Service, *fakeGateway, *gorm.DB) {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{})
@ -274,12 +436,13 @@ func testRewardGroup(id int64, name string) integration.RewardGroupDetail {
Name: name, Name: name,
RewardConfigList: []integration.RewardGroupItem{ RewardConfigList: []integration.RewardGroupItem{
{ {
ID: integration.Int64Value(11), ID: integration.Int64Value(11),
Type: "PROPS", Type: "PROPS",
Name: name + "奖励", DetailType: "RIDE",
Content: "10001", Name: name + "奖励",
Quantity: integration.Int64Value(1), Content: "10001",
Cover: "https://example.com/reward.png", Quantity: integration.Int64Value(1),
Cover: "https://example.com/reward.png",
}, },
}, },
} }

View File

@ -176,6 +176,10 @@ type RechargeEvent struct {
Currency string `json:"currency"` Currency string `json:"currency"`
PayPlatform string `json:"payPlatform"` PayPlatform string `json:"payPlatform"`
PaymentMethod string `json:"paymentMethod"` PaymentMethod string `json:"paymentMethod"`
AppVersion flexibleString `json:"appVersion"`
ClientVersion flexibleString `json:"clientVersion"`
Version flexibleString `json:"version"`
ReqVersion flexibleString `json:"reqVersion"`
SourceOrderID flexibleString `json:"sourceOrderId"` SourceOrderID flexibleString `json:"sourceOrderId"`
OrderID flexibleString `json:"orderId"` OrderID flexibleString `json:"orderId"`
OccurredAt flexibleString `json:"occurredAt"` OccurredAt flexibleString `json:"occurredAt"`
@ -219,6 +223,7 @@ type normalizedRechargeEvent struct {
Currency string Currency string
PayPlatform string PayPlatform string
PaymentMethod string PaymentMethod string
AppVersion string
SourceOrderID string SourceOrderID string
OccurredAt time.Time OccurredAt time.Time
} }

View File

@ -0,0 +1,75 @@
package firstrechargereward
import (
"strconv"
"strings"
)
const firstRechargeRewardMinAppVersion = "1.5.0"
func isFirstRechargeRewardAppVersionSupported(appVersion string) bool {
appVersion = strings.TrimSpace(appVersion)
if appVersion == "" {
return false
}
return compareAppVersion(appVersion, firstRechargeRewardMinAppVersion) >= 0
}
func compareAppVersion(left, right string) int {
leftParts := parseAppVersion(left)
rightParts := parseAppVersion(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
var lv, rv int
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv > rv {
return 1
}
if lv < rv {
return -1
}
}
return 0
}
func parseAppVersion(version string) []int {
version = strings.TrimSpace(version)
if version == "" {
return []int{0}
}
parts := strings.Split(version, ".")
result := make([]int, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
result = append(result, 0)
continue
}
digits := strings.Builder{}
for _, r := range part {
if r < '0' || r > '9' {
break
}
digits.WriteRune(r)
}
if digits.Len() == 0 {
result = append(result, 0)
continue
}
value, err := strconv.Atoi(digits.String())
if err != nil {
result = append(result, 0)
continue
}
result = append(result, value)
}
return result
}