fix: validate explicit migrations and preserve large business ids

This commit is contained in:
Yvan
2026-11-06 07:00:05 +08:00
parent f6f2a4745e
commit 0f0ec2e6d7
53 changed files with 2653 additions and 1445 deletions
+1 -1
View File
@@ -117,5 +117,5 @@ func (s *BindingService) Unbind(ctx context.Context, req *dto.BusinessBindingReq
if err != nil {
return err
}
return s.uc.Unbind(ctx, business.PlatformKey{SourceDB: source, BusierID: req.PlatformBusierID})
return s.uc.Unbind(ctx, business.PlatformKey{SourceDB: source, BusierID: uint64(req.PlatformBusierID)})
}
+8 -2
View File
@@ -21,6 +21,12 @@ func optionalNumber[T ~int | ~int64](value *T) ledgerCell {
}
return numericCell(strconv.FormatInt(int64(*value), 10))
}
func optionalID(value *int64) ledgerCell {
if value == nil {
return textCell("")
}
return textCell(strconv.FormatInt(*value, 10))
}
func optionalMoney(value *string) ledgerCell {
if value == nil {
return textCell("")
@@ -83,7 +89,7 @@ func (s *LedgerService) ExportRegistrations(ctx context.Context, req *dto.Ledger
rows := make([][]ledgerCell, 0, len(items))
for _, item := range items {
r := registrationResponse(item)
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), numericCell(strconv.FormatUint(r.ID, 10)), numericCell(strconv.FormatUint(r.UserID, 10)), optionalNumber(r.RegisterTime), optionalNumber(r.FirstGameID), textCell(r.FirstGameName), optionalMoney(r.TotalPayAmount), textCell(r.RegisterIP), textCell(r.DeviceName), textCell(r.PromoteAccount), optionalNumber(r.PromoteID), textCell(r.BusierName)})
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), textCell(strconv.FormatUint(r.ID, 10)), textCell(strconv.FormatUint(r.UserID, 10)), optionalNumber(r.RegisterTime), optionalID(r.FirstGameID), textCell(r.FirstGameName), optionalMoney(r.TotalPayAmount), textCell(r.RegisterIP), textCell(r.DeviceName), textCell(r.PromoteAccount), optionalID(r.PromoteID), textCell(r.BusierName)})
}
return ledgerWorkbook("注册明细", []string{"sourceDb", "所属平台", "ID", "玩家ID", "注册时间", "fgameId", "游戏", "累计充值", "注册IP", "机型", "渠道", "promoteId", "所属商务"}, rows)
}
@@ -99,7 +105,7 @@ func (s *LedgerService) ExportSpends(ctx context.Context, req *dto.LedgerQuery)
rows := make([][]ledgerCell, 0, len(items))
for _, item := range items {
r := spendResponse(item)
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), numericCell(strconv.FormatUint(r.ID, 10)), optionalNumber(r.UserID), textCell(r.OrderNumber), optionalNumber(r.GameID), textCell(r.GameName), textCell(r.ServerID), textCell(r.ServerName), textCell(r.PlayerName), optionalMoney(r.PayAmount), optionalNumber(r.PayTime), optionalNumber(r.PayWay), optionalNumber(r.PayStatus), textCell(r.IP), textCell(r.PromoteAccount), optionalNumber(r.PromoteID), textCell(r.BusierName)})
rows = append(rows, []ledgerCell{textCell(r.SourceDB), textCell(r.SourceDBLabel), textCell(strconv.FormatUint(r.ID, 10)), optionalID(r.UserID), textCell(r.OrderNumber), optionalID(r.GameID), textCell(r.GameName), textCell(r.ServerID), textCell(r.ServerName), textCell(r.PlayerName), optionalMoney(r.PayAmount), optionalNumber(r.PayTime), optionalNumber(r.PayWay), optionalNumber(r.PayStatus), textCell(r.IP), textCell(r.PromoteAccount), optionalID(r.PromoteID), textCell(r.BusierName)})
}
return ledgerWorkbook("充值明细", []string{"sourceDb", "所属平台", "ID", "玩家ID", "订单号", "gameId", "游戏", "serverId", "区服", "角色", "实付金额", "完成时间", "方式", "状态", "IP", "渠道", "promoteId", "所属商务"}, rows)
}
@@ -15,11 +15,11 @@ type ledgerExportFake struct{ LedgerUsecase }
func (ledgerExportFake) ExportRegistrations(context.Context, business.LedgerFilter) ([]*business.Registration, error) {
amount := "9007199254740993.10"
stamp := int64(1793577600)
return []*business.Registration{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 7}, RegisterTime: &stamp, FirstGameName: "游戏", TotalPayAmount: &amount, LedgerNames: business.LedgerNames{PromoteAccount: "=1+1"}}}, nil
return []*business.Registration{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 9007199254740993}, RegisterTime: &stamp, FirstGameName: "游戏", TotalPayAmount: &amount, LedgerNames: business.LedgerNames{PromoteAccount: "=1+1"}}}, nil
}
func (ledgerExportFake) ExportSpends(context.Context, business.LedgerFilter) ([]*business.Spend, error) {
amount := "123.45"
return []*business.Spend{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 8}, OrderNumber: "000000000000000001", PayAmount: &amount}}, nil
return []*business.Spend{{ChannelKey: business.ChannelKey{SourceDB: "lx", ID: 9007199254740995}, OrderNumber: "000000000000000001", PayAmount: &amount}}, nil
}
func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) {
s := &LedgerService{uc: ledgerExportFake{}}
@@ -41,6 +41,13 @@ func TestLedgerWorkbookPreservesNumbersAndText(t *testing.T) {
if amount != "9007199254740993.10" || formula != "" || text != "=1+1" {
t.Fatalf("导出丢失金额精度或把账号当成公式: %s %s %s", amount, formula, text)
}
for _, cell := range []string{"C2", "D2"} {
value, _ := f.GetCellValue("注册明细", cell)
kind, _ := f.GetCellType("注册明细", cell)
if value != "9007199254740993" || kind != excelize.CellTypeSharedString {
t.Fatalf("ID must be exact text: %s %v", value, kind)
}
}
rows, err := f.GetRows("注册明细")
if err != nil || len(rows) != 2 || rows[0][0] != "sourceDb" || rows[0][12] != "所属商务" {
t.Fatal("原导出字段与顺序丢失")
+2 -2
View File
@@ -26,7 +26,7 @@ type AnalyticsMetrics struct {
type ChannelAnalyticsRow struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
PromoteID *int64 `json:"promoteId"`
PromoteID *int64 `json:"promoteId,string"`
PromoteName string `json:"promoteName"`
PromoteType *int `json:"promoteType"`
BusierName string `json:"busierName"`
@@ -35,7 +35,7 @@ type ChannelAnalyticsRow struct {
type GameAnalyticsRow struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
GameID *int64 `json:"gameId"`
GameID *int64 `json:"gameId,string"`
GameName string `json:"gameName"`
AnalyticsMetrics
ARPU string `json:"arpu"`
+2 -2
View File
@@ -11,13 +11,13 @@ type BusinessBindingRequest struct {
SourceDB string `json:"sourceDb"`
Account string `json:"account"`
Password string `json:"password"`
PlatformBusierID uint64 `json:"platformBusierId"`
PlatformBusierID ID `json:"platformBusierId"`
}
type BusinessBindingResponse struct {
UserID uint `json:"userId"`
SourceDB string `json:"sourceDb"`
PlatformName string `json:"platformName"`
PlatformBusierID uint64 `json:"platformBusierId"`
PlatformBusierID uint64 `json:"platformBusierId,string"`
Account string `json:"platformBusierAccount"`
UserName string `json:"bindUserName"`
PromoteCount *int64 `json:"promoteCount"`
+1 -1
View File
@@ -45,7 +45,7 @@ type PortraitRank struct {
Rank int `json:"rank"`
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
PromoteID uint64 `json:"promoteId"`
PromoteID uint64 `json:"promoteId,string"`
PromoteName string `json:"promoteName"`
Value string `json:"value"`
}
+2 -2
View File
@@ -7,11 +7,11 @@ type BusinessPromoteQuery struct {
PlatformBusierID uint64 `form:"platformBusierId"`
}
type BusinessPromoteResponse struct {
PromoteID uint64 `json:"promoteId"`
PromoteID uint64 `json:"promoteId,string"`
PromoteName string `json:"promoteName"`
SourceDB string `json:"sourceDb"`
PlatformName string `json:"platformName"`
PlatformBusierID uint64 `json:"platformBusierId"`
PlatformBusierID uint64 `json:"platformBusierId,string"`
}
type BusinessPromotePage struct {
Rows []*BusinessPromoteResponse `json:"rows"`
+6 -6
View File
@@ -30,17 +30,17 @@ type ChannelResponse struct {
LastLoginAt *int64 `json:"lastLoginAt"`
Phone string `json:"phone"`
SettlementDays *int `json:"settlementDays"`
ID uint64 `json:"id"`
ID uint64 `json:"id,string"`
SourceDB string `json:"sourceDb"`
PlatformName string `json:"platformName"`
Account string `json:"account"`
Nickname string `json:"nickname"`
Level *int `json:"promoteLevel"`
ParentID uint64 `json:"parentId"`
ParentID uint64 `json:"parentId,string"`
ParentName string `json:"parentName"`
RealName string `json:"realName"`
RegisterType *int `json:"registerType"`
BusierID uint64 `json:"busierId"`
BusierID uint64 `json:"busierId,string"`
BusierUserID *uint `json:"busierUserId"`
BusierDisplayName string `json:"busierDisplayName"`
Status *int `json:"status"`
@@ -62,7 +62,7 @@ type ChannelStatusCounts struct {
}
type ChannelBindOption struct {
SourceDB string `json:"sourceDb"`
PlatformBusierID uint64 `json:"platformBusierId"`
PlatformBusierID uint64 `json:"platformBusierId,string"`
Account string `json:"platformBusierAccount"`
UserID uint `json:"userId"`
}
@@ -74,7 +74,7 @@ type ChannelCreateRequest struct {
RegisterType *int `json:"registerType"`
}
type ChannelCreateResult struct {
ID uint64 `json:"id"`
ID uint64 `json:"id,string"`
SourceDB string `json:"sourceDb"`
}
type ChannelGameQuery struct {
@@ -82,7 +82,7 @@ type ChannelGameQuery struct {
PromoteID uint64 `form:"promoteId"`
}
type ChannelGameResponse struct {
GameID uint64 `json:"gameId"`
GameID uint64 `json:"gameId,string"`
GameName string `json:"gameName"`
RegisterCount int64 `json:"registerCount"`
PayAmount string `json:"payAmount"`
+2 -2
View File
@@ -6,7 +6,7 @@ type ChannelPortraitQuery struct {
Period string `form:"period"`
}
type ChannelPortraitBasic struct {
PromoteID uint64 `json:"promoteId"`
PromoteID uint64 `json:"promoteId,string"`
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
Account string `json:"account"`
@@ -26,7 +26,7 @@ type ChannelPortraitBasic struct {
LastLoginTime *string `json:"lastLoginTime"`
BusierUserID *uint `json:"busierUserId"`
BusierDisplayName string `json:"busierDisplayName"`
ParentPromoteID *uint64 `json:"parentPromoteId"`
ParentPromoteID *uint64 `json:"parentPromoteId,string"`
ParentPromoteName *string `json:"parentPromoteName"`
ParentPromoteAccount *string `json:"parentPromoteAccount"`
GameCount *int64 `json:"gameCount"`
+38
View File
@@ -0,0 +1,38 @@
package dto
import (
"encoding/json"
"fmt"
"strconv"
)
// ID accepts legacy JSON integers and decimal strings. Its wire representation
// is always a string so a browser can round-trip all uint64 values exactly.
type ID uint64
func (id ID) MarshalJSON() ([]byte, error) {
return json.Marshal(strconv.FormatUint(uint64(id), 10))
}
func (id *ID) UnmarshalJSON(raw []byte) error {
text := string(raw)
if len(raw) > 0 && raw[0] == '"' {
if err := json.Unmarshal(raw, &text); err != nil {
return err
}
}
if text == "" {
return fmt.Errorf("ID must be a decimal integer")
}
for _, digit := range text {
if digit < '0' || digit > '9' {
return fmt.Errorf("ID must be a decimal integer")
}
}
value, err := strconv.ParseUint(text, 10, 64)
if err != nil {
return fmt.Errorf("ID is outside the uint64 range")
}
*id = ID(value)
return nil
}
+68
View File
@@ -0,0 +1,68 @@
package dto
import (
"encoding/json"
"math"
"strconv"
"testing"
)
func TestIDRoundTripsDecimalStringsAndLegacyNumbers(t *testing.T) {
for _, value := range []uint64{1, math.MaxInt32, math.MaxInt32 + 1, 1<<53 - 1, 1 << 53, 1<<53 + 1, math.MaxInt64, math.MaxUint64} {
want := strconv.FormatUint(value, 10)
for _, raw := range []string{want, strconv.Quote(want)} {
var request BusinessBindingRequest
if err := json.Unmarshal([]byte(`{"platformBusierId":`+raw+`}`), &request); err != nil || uint64(request.PlatformBusierID) != value {
t.Fatalf("decode %s: %v, %d", raw, err, request.PlatformBusierID)
}
encoded, err := json.Marshal(request.PlatformBusierID)
if err != nil || string(encoded) != strconv.Quote(want) {
t.Fatalf("encode %s: %s, %v", raw, encoded, err)
}
}
}
for _, raw := range []string{`null`, `true`, `-1`, `1.5`, `1e3`, `18446744073709551616`, `""`, `"-1"`, `"1.0"`, `"1e3"`, `" 1"`, `"+1"`, `"18446744073709551616"`} {
var id ID
if err := json.Unmarshal([]byte(raw), &id); err == nil {
t.Fatalf("accepted invalid ID %s", raw)
}
}
}
func TestLegacyResponsesPreserveLargeIDsAndNumericMetrics(t *testing.T) {
const value = uint64(1<<53 + 1)
signed := int64(value)
for _, response := range []any{
ChannelResponse{ID: value, ParentID: value, BusierID: value},
ChannelCreateResult{ID: value}, ChannelBindOption{PlatformBusierID: value},
ChannelGameResponse{GameID: value, RegisterCount: 3},
BusinessBindingResponse{PlatformBusierID: value},
BusinessPromoteResponse{PromoteID: value, PlatformBusierID: value},
ChannelPortraitBasic{PromoteID: value, ParentPromoteID: func() *uint64 { v := value; return &v }()},
RegistrationResponse{ID: value, UserID: value, FirstGameID: &signed, PromoteID: &signed},
SpendResponse{ID: value, UserID: &signed, GameID: &signed, PromoteID: &signed},
ChannelAnalyticsRow{PromoteID: &signed}, GameAnalyticsRow{GameID: &signed},
PerformanceChannel{ID: value}, PortraitRank{PromoteID: value},
} {
raw, err := json.Marshal(response)
if err != nil {
t.Fatal(err)
}
var browser map[string]any
if err := json.Unmarshal(raw, &browser); err != nil {
t.Fatal(err)
}
for _, key := range []string{"id", "userId", "parentId", "busierId", "platformBusierId", "promoteId", "parentPromoteId", "gameId", "fgameId"} {
actual, exists := browser[key]
if !exists || actual == nil || actual == float64(0) || actual == "0" {
continue
}
if actual != "9007199254740993" {
t.Fatalf("%T.%s lost ID: %v", response, key, actual)
}
}
if count, ok := browser["registerCount"]; ok && count != float64(3) {
t.Fatalf("metric changed type: %v", count)
}
}
}
+9 -9
View File
@@ -22,25 +22,25 @@ type LedgerQuery struct {
type RegistrationResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
ID uint64 `json:"id"`
UserID uint64 `json:"userId"`
ID uint64 `json:"id,string"`
UserID uint64 `json:"userId,string"`
RegisterTime *int64 `json:"registerTime"`
FirstGameID *int64 `json:"fgameId"`
FirstGameID *int64 `json:"fgameId,string"`
FirstGameName string `json:"fgameName"`
TotalPayAmount *string `json:"totalPayAmount"`
RegisterIP string `json:"registerIp"`
DeviceName string `json:"deviceName"`
PromoteAccount string `json:"promoteAccount"`
PromoteID *int64 `json:"promoteId"`
PromoteID *int64 `json:"promoteId,string"`
BusierName string `json:"busierName"`
}
type SpendResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
ID uint64 `json:"id"`
UserID *int64 `json:"userId"`
ID uint64 `json:"id,string"`
UserID *int64 `json:"userId,string"`
OrderNumber string `json:"payOrderNumber"`
GameID *int64 `json:"gameId"`
GameID *int64 `json:"gameId,string"`
GameName string `json:"gameName"`
ServerID string `json:"serverId"`
ServerName string `json:"serverName"`
@@ -51,7 +51,7 @@ type SpendResponse struct {
PayStatus *int `json:"payStatus"`
IP string `json:"spendIp"`
PromoteAccount string `json:"promoteAccount"`
PromoteID *int64 `json:"promoteId"`
PromoteID *int64 `json:"promoteId,string"`
BusierName string `json:"busierName"`
}
type RegistrationPage struct {
@@ -73,6 +73,6 @@ type GameOptionQuery struct {
type GameOptionResponse struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
GameID uint64 `json:"gameId"`
GameID uint64 `json:"gameId,string"`
GameName string `json:"gameName"`
}
+1 -1
View File
@@ -138,7 +138,7 @@ type PerformanceChannel struct {
ContributorName string `json:"contributorName"`
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
ID uint64 `json:"id"`
ID uint64 `json:"id,string"`
Account string `json:"account"`
Name string `json:"name"`
OwnerName string `json:"ownerName"`
+1 -1
View File
@@ -25,7 +25,7 @@ type ServerOptionResponse struct {
type ServerAnalysisRow struct {
SourceDB string `json:"sourceDb"`
SourceDBLabel string `json:"sourceDbLabel"`
GameID *int64 `json:"gameId"`
GameID *int64 `json:"gameId,string"`
GameName string `json:"gameName"`
ServerOptionResponse
Register int64 `json:"newRegisterCount"`