fix: validate explicit migrations and preserve large business ids
This commit is contained in:
@@ -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)})
|
||||
}
|
||||
|
||||
@@ -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("原导出字段与顺序丢失")
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user