feat: 迁移游戏商务绑定审核流程和列表

This commit is contained in:
Yvan
2026-11-03 18:26:26 +08:00
parent 43f0ec1b15
commit c0a3692068
31 changed files with 1304 additions and 30 deletions
+1 -1
View File
@@ -11,4 +11,4 @@ import (
)
// ProviderSet is biz providers.
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase)
var ProviderSet = wire.NewSet(system.ProviderSet, payment.ProviderSet, integration.ProviderSet, task.ProviderSet, business.NewUserPreferenceUsecase, business.NewAccountUsecase, business.NewBindingUsecase)
+189
View File
@@ -0,0 +1,189 @@
package business
import (
"context"
"github.com/go-kratos/kratos/v3/errors"
"strings"
"time"
)
var (
ErrPlatform = errors.BadRequest("SW_PLATFORM_INVALID", "所属平台不正确")
ErrPlatformUnavailable = errors.BadRequest("SW_PLATFORM_UNAVAILABLE", "平台数据源未配置或暂不可用")
ErrPlatformPasswordConfig = errors.BadRequest("SW_PLATFORM_PASSWORD_CONFIG", "平台密码兼容配置未就绪")
ErrPlatformAccount = errors.BadRequest("SW_PLATFORM_ACCOUNT", "游戏商务账号不存在或已禁用")
ErrPlatformPassword = errors.BadRequest("SW_PLATFORM_PASSWORD", "游戏商务账号或密码错误")
ErrBindingLevel = errors.BadRequest("SW_BINDING_LEVEL", "仅三级商务可绑定游戏 SDK 商务")
ErrBindingOccupied = errors.BadRequest("SW_BINDING_OCCUPIED", "该游戏商务已被其他账号绑定或审核中")
ErrBindingExists = errors.BadRequest("SW_BINDING_EXISTS", "该游戏商务已绑定")
ErrBindingPending = errors.BadRequest("SW_BINDING_PENDING", "该游戏商务已提交,请等待 OA 审核")
ErrBindingNotFound = errors.NotFound("SW_BINDING_NOT_FOUND", "绑定记录不存在")
)
type PlatformKey struct {
SourceDB string
BusierID uint64
}
type PlatformAccount struct {
ID uint64
Account, Password string
Status int
}
type PlatformAccountRepo interface {
FindByAccount(context.Context, string, string) (*PlatformAccount, error)
}
type PlatformPasswordVerifier interface {
Verify(string, string) (bool, error)
}
func NormalizePlatform(value string) (string, error) {
value = strings.ToLower(strings.TrimSpace(value))
switch value {
case "lx", "hw", "yq":
return value, nil
default:
return "", ErrPlatform
}
}
func PlatformName(value string) string {
switch strings.ToLower(strings.TrimSpace(value)) {
case "lx":
return "龙翔"
case "hw":
return "汇玩"
case "yq":
return "游趣"
default:
return value
}
}
type Binding struct {
UserID uint
PlatformKey
Account, UserName string
Status string
RejectReason *string
CreatedAt time.Time
ReviewTime *time.Time
ReviewAccount *string
PromoteCount *int64
}
type BindingFilter struct {
UserID uint
SourceDBs []string
Account string
Status string
}
type BindingRepo interface {
List(context.Context, UserScope, BindingFilter, ...ListOption) ([]*Binding, int64, error)
WithPlatformLock(context.Context, PlatformKey, func(context.Context) error) error
ForPlatform(context.Context, PlatformKey) ([]*Binding, error)
Save(context.Context, *Binding) error
Delete(context.Context, uint, PlatformKey) error
}
type BindingUsecase struct {
repo BindingRepo
accounts *AccountUsecase
platforms PlatformAccountRepo
passwords PlatformPasswordVerifier
}
func NewBindingUsecase(repo BindingRepo, accounts *AccountUsecase, platforms PlatformAccountRepo, passwords PlatformPasswordVerifier) *BindingUsecase {
return &BindingUsecase{repo: repo, accounts: accounts, platforms: platforms, passwords: passwords}
}
func (uc *BindingUsecase) List(ctx context.Context, q BindingFilter, options ...ListOption) ([]*Binding, int64, error) {
actor, current, err := uc.accounts.actor(ctx)
if err != nil {
return nil, 0, err
}
scope := UserScope{All: actor.AuthorityID == 888}
if !scope.All {
scope.SelfID = current.ID
if current.Level >= 3 {
scope.OnlySelf = true
} else {
scope.DescendantPath = descendantPath(current)
}
}
return uc.repo.List(ctx, scope, q, options...)
}
func (uc *BindingUsecase) ForUser(ctx context.Context, id uint) ([]*Binding, error) {
if _, err := uc.accounts.Find(ctx, id); err != nil {
return nil, err
}
rows, _, err := uc.repo.List(ctx, UserScope{SelfID: id, OnlySelf: true}, BindingFilter{UserID: id, Status: "0"}, ListLimit(-1))
return rows, err
}
func (uc *BindingUsecase) Bind(ctx context.Context, source, account, password string) error {
_, current, err := uc.accounts.actor(ctx)
if err != nil {
return err
}
if current.Level != 3 {
return ErrBindingLevel
}
source, err = NormalizePlatform(source)
if err != nil {
return err
}
account = strings.TrimSpace(account)
platform, err := uc.platforms.FindByAccount(ctx, source, account)
if err != nil {
return err
}
if platform == nil || platform.Status != 1 {
return ErrPlatformAccount
}
valid, err := uc.passwords.Verify(password, platform.Password)
if err != nil {
return err
}
if !valid {
return ErrPlatformPassword
}
key := PlatformKey{SourceDB: source, BusierID: platform.ID}
// 平台验密完成后才开启主库事务,避免跨库事务切源。
return uc.repo.WithPlatformLock(ctx, key, func(txctx context.Context) error {
bindings, err := uc.repo.ForPlatform(txctx, key)
if err != nil {
return err
}
for _, b := range bindings {
if b.UserID != current.ID && (b.Status == "0" || b.Status == "3") {
return ErrBindingOccupied
}
}
for _, b := range bindings {
if b.UserID == current.ID {
if b.Status == "0" {
return ErrBindingExists
}
if b.Status == "3" {
return ErrBindingPending
}
}
}
return uc.repo.Save(txctx, &Binding{UserID: current.ID, PlatformKey: key, Account: account, Status: "3", CreatedAt: time.Now()})
})
}
func (uc *BindingUsecase) Unbind(ctx context.Context, key PlatformKey) error {
_, current, err := uc.accounts.actor(ctx)
if err != nil {
return err
}
key.SourceDB, err = NormalizePlatform(key.SourceDB)
if err != nil {
return err
}
if key.BusierID == 0 {
return ErrBindingNotFound
}
return uc.repo.WithPlatformLock(ctx, key, func(txctx context.Context) error { return uc.repo.Delete(txctx, current.ID, key) })
}
+112
View File
@@ -0,0 +1,112 @@
package business
import (
"context"
"sync"
"testing"
)
type bindingRepoFake struct {
BindingRepo
mu sync.Mutex
rows []*Binding
saved *Binding
scope UserScope
filter BindingFilter
}
func (r *bindingRepoFake) WithPlatformLock(ctx context.Context, _ PlatformKey, fn func(context.Context) error) error {
r.mu.Lock()
defer r.mu.Unlock()
return fn(ctx)
}
func (r *bindingRepoFake) ForPlatform(context.Context, PlatformKey) ([]*Binding, error) {
return r.rows, nil
}
func (r *bindingRepoFake) Save(_ context.Context, b *Binding) error {
r.saved = b
r.rows = []*Binding{b}
return nil
}
func (r *bindingRepoFake) List(_ context.Context, s UserScope, q BindingFilter, _ ...ListOption) ([]*Binding, int64, error) {
r.scope, r.filter = s, q
return []*Binding{}, 0, nil
}
type platformRepoFake struct {
account *PlatformAccount
calls int
}
func (r *platformRepoFake) FindByAccount(context.Context, string, string) (*PlatformAccount, error) {
r.calls++
return r.account, nil
}
type passwordFake struct{}
func (passwordFake) Verify(string, string) (bool, error) { return true, nil }
func newBindingTestUsecase(repo *bindingRepoFake) (*BindingUsecase, *platformRepoFake) {
a := &accountRepoFake{accounts: map[uint]*Account{1: {ID: 1, Level: 1, IDPath: "0"}, 2: {ID: 2, Level: 2, IDPath: "0,1", ParentID: 1}, 3: {ID: 3, Level: 3, IDPath: "0,1,2", ParentID: 2}, 4: {ID: 4, Level: 3, IDPath: "0,1,2", ParentID: 2}}}
p := &platformRepoFake{account: &PlatformAccount{ID: 7, Status: 1}}
return NewBindingUsecase(repo, NewAccountUsecase(a), p, passwordFake{}), p
}
func TestBindingTransitionsAndLevel(t *testing.T) {
for _, item := range []struct {
name string
existing *Binding
want error
}{
{"新增", nil, nil}, {"重提驳回", &Binding{UserID: 3, Status: "2"}, nil}, {"本人通过", &Binding{UserID: 3, Status: "0"}, ErrBindingExists},
{"本人待审核", &Binding{UserID: 3, Status: "3"}, ErrBindingPending}, {"其他账号通过", &Binding{UserID: 4, Status: "0"}, ErrBindingOccupied}, {"其他账号待审核", &Binding{UserID: 4, Status: "3"}, ErrBindingOccupied},
} {
t.Run(item.name, func(t *testing.T) {
r := &bindingRepoFake{}
if item.existing != nil {
r.rows = []*Binding{item.existing}
}
uc, _ := newBindingTestUsecase(r)
if err := uc.Bind(accountContext(3, 903), "LX", " sdk ", "password"); err != item.want {
t.Fatalf("绑定结果错误: %v", err)
}
if item.want == nil && (r.saved.Status != "3" || r.saved.Account != "sdk" || r.saved.SourceDB != "lx" || r.saved.RejectReason != nil || r.saved.ReviewTime != nil || r.saved.ReviewAccount != nil) {
t.Fatalf("待审核状态未正确重置: %+v", r.saved)
}
})
}
r := &bindingRepoFake{}
uc, p := newBindingTestUsecase(r)
if err := uc.Bind(accountContext(2, 902), "lx", "sdk", "password"); err != ErrBindingLevel || p.calls != 0 {
t.Fatal("非三级不应访问平台验密")
}
}
func TestBindingScopesMatchBusinessLevels(t *testing.T) {
r := &bindingRepoFake{}
uc, _ := newBindingTestUsecase(r)
if _, _, err := uc.List(accountContext(1, 901), BindingFilter{}); err != nil || r.scope.IncludeSelf || r.scope.OnlySelf || r.scope.DescendantPath != "0,1" {
t.Fatalf("一级仅查看下级绑定: %+v %v", r.scope, err)
}
if _, _, err := uc.List(accountContext(3, 903), BindingFilter{}); err != nil || !r.scope.OnlySelf || r.scope.SelfID != 3 {
t.Fatal("三级只能查看自己的绑定")
}
if _, err := uc.ForUser(accountContext(1, 901), 3); err != nil || r.filter.Status != "0" || r.filter.UserID != 3 {
t.Fatal("绑定详情必须仅已通过")
}
}
func TestBindingConcurrentSubmissionHasOneOwner(t *testing.T) {
r := &bindingRepoFake{}
uc, _ := newBindingTestUsecase(r)
// 每个用例独立的平台桩避免把桩的计数器带进并发断言。
other, _ := newBindingTestUsecase(r)
errors := make(chan error, 2)
go func() { errors <- uc.Bind(accountContext(3, 903), "lx", "sdk", "password") }()
go func() { errors <- other.Bind(accountContext(4, 903), "lx", "sdk", "password") }()
first, second := <-errors, <-errors
if !((first == nil && second == ErrBindingOccupied) || (second == nil && first == ErrBindingOccupied)) {
t.Fatalf("同一平台商务必须独占: %v %v", first, second)
}
}
+5 -10
View File
@@ -153,7 +153,7 @@ func checkAccountUnique(tx *gorm.DB, a *business.Account) error {
}
func (r *accountRepo) Create(ctx context.Context, a *business.Account) error {
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := checkAccountUnique(tx, a); err != nil {
return err
}
@@ -171,10 +171,11 @@ func (r *accountRepo) Create(ctx context.Context, a *business.Account) error {
a.ID, a.CreatedAt, a.UpdatedAt = po.ID, po.CreatedAt, po.UpdatedAt
return nil
})
return storageError(err)
}
func (r *accountRepo) Update(ctx context.Context, a *business.Account) error {
return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := checkAccountUnique(tx, a); err != nil {
return err
}
@@ -194,6 +195,7 @@ func (r *accountRepo) Update(ctx context.Context, a *business.Account) error {
}
return nil
})
return storageError(err)
}
func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, error) {
@@ -250,17 +252,10 @@ func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint]bu
result[b.UserID] = business.AccountCounts{PromoteCount: &zero, BindCount: b.Total}
}
// 系统数据演练库尚未包含渠道表时,基础资料与已通过绑定数仍可使用。
tables, err := db.Migrator().GetTables()
available, err := businessTableAvailable(db, "tab_promote")
if err != nil {
return nil, business.ErrStorage
}
available := false
for _, table := range tables {
if table == "tab_promote" {
available = true
break
}
}
if !available {
for _, b := range binds {
value := result[b.UserID]
+161
View File
@@ -0,0 +1,161 @@
package business
import (
"context"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"kra/internal/biz/business"
)
type bindingLockPO struct {
SourceDB string `gorm:"column:source_db;primaryKey;size:50"`
PlatformBusierID uint64 `gorm:"primaryKey;autoIncrement:false"`
}
func (bindingLockPO) TableName() string { return "sys_business_binding_locks" }
type bindingRepo struct{ data Provider }
func NewBindingRepo(data Provider) business.BindingRepo { return &bindingRepo{data: data} }
type bindingTxKey struct{}
func (r *bindingRepo) db(ctx context.Context) *gorm.DB {
if tx, ok := ctx.Value(bindingTxKey{}).(*gorm.DB); ok {
return tx.WithContext(ctx)
}
return r.data.DB().WithContext(ctx)
}
func (r *bindingRepo) WithPlatformLock(ctx context.Context, key business.PlatformKey, fn func(context.Context) error) error {
err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 单独的键锁覆盖“尚无绑定记录”的并发场景,不修改平台数据库。
lock := bindingLockPO{SourceDB: key.SourceDB, PlatformBusierID: key.BusierID}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&lock).Error; err != nil {
return business.ErrStorage
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("source_db = ? AND platform_busier_id = ?", key.SourceDB, key.BusierID).First(&lock).Error; err != nil {
return business.ErrStorage
}
return fn(context.WithValue(ctx, bindingTxKey{}, tx))
})
return storageError(err)
}
func toBizBinding(po bindingPO) *business.Binding {
return &business.Binding{UserID: po.UserID, PlatformKey: business.PlatformKey{SourceDB: po.SourceDB, BusierID: po.PlatformBusierID}, Account: po.PlatformBusierAccount,
Status: po.Status, RejectReason: po.RejectReason, CreatedAt: po.CreateTime, ReviewTime: po.ReviewTime, ReviewAccount: po.ReviewAccount}
}
func newBinding(b *business.Binding) bindingPO {
return bindingPO{UserID: b.UserID, SourceDB: b.SourceDB, PlatformBusierID: b.BusierID, PlatformBusierAccount: b.Account, Status: b.Status,
RejectReason: b.RejectReason, CreateTime: b.CreatedAt, ReviewTime: b.ReviewTime, ReviewAccount: b.ReviewAccount}
}
func (r *bindingRepo) ForPlatform(ctx context.Context, key business.PlatformKey) ([]*business.Binding, error) {
var pos []bindingPO
if err := r.db(ctx).Where("source_db = ? AND platform_busier_id = ?", key.SourceDB, key.BusierID).Find(&pos).Error; err != nil {
return nil, business.ErrStorage
}
rows := make([]*business.Binding, 0, len(pos))
for _, po := range pos {
rows = append(rows, toBizBinding(po))
}
return rows, nil
}
func (r *bindingRepo) Save(ctx context.Context, b *business.Binding) error {
po := newBinding(b)
err := r.db(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "user_id"}, {Name: "source_db"}, {Name: "platform_busier_id"}},
DoUpdates: clause.AssignmentColumns([]string{"platform_busier_account", "status", "reject_reason", "create_time", "review_time", "review_account"})}).Create(&po).Error
if err != nil {
return business.ErrStorage
}
return nil
}
func (r *bindingRepo) Delete(ctx context.Context, userID uint, key business.PlatformKey) error {
result := r.db(ctx).Where("user_id = ? AND source_db = ? AND platform_busier_id = ?", userID, key.SourceDB, key.BusierID).Delete(&bindingPO{})
if result.Error != nil {
return business.ErrStorage
}
if result.RowsAffected == 0 {
return business.ErrBindingNotFound
}
return nil
}
func (r *bindingRepo) List(ctx context.Context, scope business.UserScope, q business.BindingFilter, options ...business.ListOption) ([]*business.Binding, int64, error) {
db := r.db(ctx).Table("sys_user_bind_platform b").Joins("JOIN sys_users u ON u.id=b.user_id AND u.deleted_at IS NULL")
db = applyUserScope(db, scope, "u.")
if q.UserID != 0 {
db = db.Where("b.user_id = ?", q.UserID)
}
if len(q.SourceDBs) > 0 {
db = db.Where("b.source_db IN ?", q.SourceDBs)
}
if q.Account != "" {
db = db.Where("b.platform_busier_account LIKE ?", "%"+q.Account+"%")
}
if q.Status != "" {
db = db.Where("b.status = ?", q.Status)
}
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, business.ErrStorage
}
o := business.NewListOptions(options...)
var pos []struct {
Binding bindingPO `gorm:"embedded"`
UserName string
}
if err := db.Select("b.*,u.username AS user_name").Order("b.create_time DESC").Order("b.source_db,b.platform_busier_id,b.user_id").Offset(o.Offset).Limit(o.Limit).Scan(&pos).Error; err != nil {
return nil, 0, business.ErrStorage
}
rows := make([]*business.Binding, 0, len(pos))
keys := make(map[business.PlatformKey]bool)
for _, po := range pos {
b := toBizBinding(po.Binding)
zero := int64(0)
b.PromoteCount = &zero
b.UserName = po.UserName
rows = append(rows, b)
if b.Status == "0" {
keys[b.PlatformKey] = true
}
}
if len(keys) == 0 {
return rows, total, nil
}
available, err := businessTableAvailable(r.db(ctx), "tab_promote")
if err != nil {
return nil, 0, err
}
if !available {
for _, b := range rows {
if b.Status == "0" {
b.PromoteCount = nil
}
}
return rows, total, nil
}
query := r.db(ctx).Table("tab_promote").Where("promote_level = ?", 1)
pairs := r.db(ctx).Session(&gorm.Session{NewDB: true})
for key := range keys {
pairs = pairs.Or("(source_db = ? AND busier_id = ?)", key.SourceDB, key.BusierID)
}
var counts []struct {
SourceDB string `gorm:"column:source_db"`
BusierID uint64
Total int64
}
if err := query.Where(pairs).Select("source_db,busier_id,COUNT(*) AS total").Group("source_db,busier_id").Scan(&counts).Error; err != nil {
return nil, 0, business.ErrStorage
}
byKey := make(map[business.PlatformKey]int64, len(counts))
for _, c := range counts {
byKey[business.PlatformKey{SourceDB: c.SourceDB, BusierID: c.BusierID}] = c.Total
}
for _, b := range rows {
if b.Status == "0" {
count := byKey[b.PlatformKey]
b.PromoteCount = &count
}
}
return rows, total, nil
}
+76
View File
@@ -0,0 +1,76 @@
package business
import (
"context"
"kra/internal/biz/business"
"testing"
"time"
)
func TestBindingRepositoryClearsAuditAndUsesCompositeIdentity(t *testing.T) {
db := prepareAccounts(t)
r := NewBindingRepo(testData{db})
ctx := context.Background()
reason, review := "旧驳回", "审核员"
now := time.Now()
key := business.PlatformKey{SourceDB: "lx", BusierID: 7}
if err := r.Save(ctx, &business.Binding{UserID: 3, PlatformKey: key, Status: "2", RejectReason: &reason, ReviewTime: &now, ReviewAccount: &review}); err != nil {
t.Fatal(err)
}
if err := r.Save(ctx, &business.Binding{UserID: 3, PlatformKey: business.PlatformKey{SourceDB: "hw", BusierID: 7}, Status: "0"}); err != nil {
t.Fatal(err)
}
if err := r.WithPlatformLock(ctx, key, func(txctx context.Context) error {
return r.Save(txctx, &business.Binding{UserID: 3, PlatformKey: key, Status: "3", CreatedAt: now})
}); err != nil {
t.Fatal(err)
}
rows, err := r.ForPlatform(ctx, key)
if err != nil || len(rows) != 1 {
t.Fatalf("查询失败: %+v %v", rows, err)
}
if rows[0].Status != "3" || rows[0].RejectReason != nil || rows[0].ReviewTime != nil || rows[0].ReviewAccount != nil {
t.Fatal("重提未清除旧审核信息")
}
if err := r.Delete(ctx, 4, key); err != business.ErrBindingNotFound {
t.Fatal("不能删除其他用户绑定")
}
if err := r.Delete(ctx, 3, key); err != nil {
t.Fatal(err)
}
other, err := r.ForPlatform(ctx, business.PlatformKey{SourceDB: "hw", BusierID: 7})
if err != nil || len(other) != 1 {
t.Fatal("解绑影响了同 ID 的其他平台")
}
}
func TestBindingListLoadsDTOFieldsAndSurvivesMissingChannels(t *testing.T) {
db := prepareAccounts(t)
if err := db.Create(&accountPO{ID: 3, Username: "child", Level: 3, IDPath: "0,1,2"}).Error; err != nil {
t.Fatal(err)
}
r := NewBindingRepo(testData{db})
ctx := context.Background()
for _, source := range []string{"lx", "hw"} {
if err := r.Save(ctx, &business.Binding{UserID: 3, PlatformKey: business.PlatformKey{SourceDB: source, BusierID: 7}, Account: "sdk", Status: "0", CreatedAt: time.Now()}); err != nil {
t.Fatal(err)
}
}
if err := db.Exec("INSERT INTO tab_promote VALUES(1,'lx',7,1,1),(2,'lx',7,2,1)").Error; err != nil {
t.Fatal(err)
}
rows, total, err := r.List(ctx, business.UserScope{OnlySelf: true, SelfID: 3}, business.BindingFilter{SourceDBs: []string{"lx"}})
if err != nil || total != 1 || len(rows) != 1 {
t.Fatalf("绑定列表错误: %+v %d %v", rows, total, err)
}
if rows[0].UserID != 3 || rows[0].UserName != "child" || rows[0].Account != "sdk" || rows[0].PromoteCount == nil || *rows[0].PromoteCount != 1 {
t.Fatalf("绑定字段或渠道统计错误: %+v", rows[0])
}
if err := db.Exec("DROP TABLE tab_promote").Error; err != nil {
t.Fatal(err)
}
rows, total, err = r.List(ctx, business.UserScope{OnlySelf: true, SelfID: 3}, business.BindingFilter{})
if err != nil || total != 2 || len(rows) != 2 || rows[0].PromoteCount != nil {
t.Fatalf("缺渠道表不应阻断绑定资料: %+v %d %v", rows, total, err)
}
}
+3 -1
View File
@@ -6,7 +6,9 @@ import (
)
func Migrations() []migration.Step {
return []migration.Step{{ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
return []migration.Step{{ID: "202611030004_business_binding_locks", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingLockPO{})
}}, {ID: "202611030003_business_bindings", Migrate: func(db *gorm.DB) error {
return migration.CreateMissingTables(db, &bindingPO{})
}}, {ID: "202611030001_business_preference", Migrate: func(db *gorm.DB) error {
// 旧表沿用;已迁移数据库不重建或重置用户偏好。
+45
View File
@@ -0,0 +1,45 @@
package business
import (
"context"
"errors"
"gorm.io/gorm"
"kra/internal/biz/business"
)
type PlatformProvider interface {
Database(string) (*gorm.DB, error)
}
type platformAccountRepo struct{ data PlatformProvider }
func NewPlatformAccountRepo(data PlatformProvider) business.PlatformAccountRepo {
return &platformAccountRepo{data: data}
}
type platformAccountPO struct {
ID uint64
Account, Password string
Status int
}
func (platformAccountPO) TableName() string { return "tab_promote_business" }
func (r *platformAccountRepo) FindByAccount(ctx context.Context, source, account string) (*business.PlatformAccount, error) {
source, err := business.NormalizePlatform(source)
if err != nil {
return nil, err
}
db, err := r.data.Database(source)
if err != nil || db == nil {
return nil, business.ErrPlatformUnavailable
}
var po platformAccountPO
err = db.WithContext(ctx).Where("account = ?", account).First(&po).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, business.ErrPlatformUnavailable
}
return &business.PlatformAccount{ID: po.ID, Account: po.Account, Password: po.Password, Status: po.Status}, nil
}
+32
View File
@@ -0,0 +1,32 @@
package business
import (
"errors"
kratoserrors "github.com/go-kratos/kratos/v3/errors"
"gorm.io/gorm"
"kra/internal/biz/business"
)
func storageError(err error) error {
if err == nil {
return nil
}
var typed *kratoserrors.Error
if errors.As(err, &typed) {
return err
}
return business.ErrStorage
}
func businessTableAvailable(db *gorm.DB, name string) (bool, error) {
tables, err := db.Migrator().GetTables()
if err != nil {
return false, business.ErrStorage
}
for _, table := range tables {
if table == name {
return true, nil
}
}
return false, nil
}
+3
View File
@@ -27,8 +27,11 @@ import (
var ProviderSet = wire.NewSet(
NewData,
wire.Bind(new(databusiness.Provider), new(*Data)),
wire.Bind(new(databusiness.PlatformProvider), new(*Data)),
databusiness.NewUserPreferenceRepo,
databusiness.NewAccountRepo,
databusiness.NewBindingRepo,
databusiness.NewPlatformAccountRepo,
NewIntegrationRuntime,
wire.Bind(new(datasystem.Provider), new(*Data)),
wire.Bind(new(datasystem.DatabaseProvider), new(*Data)),
+2 -2
View File
@@ -34,8 +34,8 @@ func TestMigrateAllRunsModuleSchemasWithoutBootstrapSeed(t *testing.T) {
if err = db.Table(migration.TableName).Count(&versions).Error; err != nil {
t.Fatal(err)
}
if versions != 17 {
t.Fatalf("migration versions = %d, want 17", versions)
if versions != 18 {
t.Fatalf("migration versions = %d, want 18", versions)
}
var communicationRows []dataintegration.ConfigPO
if err = db.Where("kind IN ?", []string{"mq", "websocket"}).Order("kind, provider").Find(&communicationRows).Error; err != nil {
@@ -0,0 +1,46 @@
package gameplatform
import (
"crypto/md5"
"crypto/sha1"
"crypto/subtle"
"encoding/hex"
"kra/internal/biz/business"
"os"
"strings"
)
type Passwords struct{ AuthCode, MigrateAuthCode, TablePrefix string }
func NewPasswords() business.PlatformPasswordVerifier {
prefix := os.Getenv("SW_SDK_TABLE_PREFIX")
if prefix == "" {
prefix = "sys_"
}
return &Passwords{AuthCode: os.Getenv("SW_SDK_AUTH_CODE"), MigrateAuthCode: os.Getenv("SW_SDK_MIGRATE_AUTH_CODE"), TablePrefix: prefix}
}
func md5Hex(value string) string { sum := md5.Sum([]byte(value)); return hex.EncodeToString(sum[:]) }
func (p *Passwords) Verify(password, stored string) (bool, error) {
if strings.TrimSpace(password) == "" || strings.TrimSpace(stored) == "" {
return false, nil
}
var expected string
switch {
case strings.HasPrefix(stored, "###"):
if p.AuthCode == "" {
return false, business.ErrPlatformPasswordConfig
}
expected = "###" + md5Hex(md5Hex(p.AuthCode+password))
case strings.HasPrefix(stored, "##$##"):
if p.MigrateAuthCode == "" {
return false, business.ErrPlatformPasswordConfig
}
sha := sha1.Sum([]byte(password))
expected = "##$##" + md5Hex(hex.EncodeToString(sha[:])+p.MigrateAuthCode)
default:
decor := md5Hex(p.TablePrefix)
expected = decor[:12] + md5Hex(password) + decor[len(decor)-4:]
}
return subtle.ConstantTimeCompare([]byte(expected), []byte(stored)) == 1, nil
}
@@ -0,0 +1,19 @@
package gameplatform
import "testing"
func TestLegacyPasswordFormats(t *testing.T) {
p := &Passwords{AuthCode: "test-auth", MigrateAuthCode: "test-migrate", TablePrefix: "sys_"}
// 固定向量由独立的 Python hashlib 按原 Java 算法生成。
for _, hash := range []string{"###eef6d9b7160f32a869f3e03b7ea575a5", "9db2515d8fcc62cc2d8b4bf2d8728120d052163a77df7f09", "##$##073cf9a979400c63c0dc69f201efcdf8"} {
if ok, err := p.Verify("demo123", hash); err != nil || !ok {
t.Fatalf("旧密码格式不兼容: %v", err)
}
if ok, _ := p.Verify("wrong", hash); ok {
t.Fatal("错误密码被接受")
}
}
if ok, err := (&Passwords{}).Verify("demo123", "###eef6d9b7160f32a869f3e03b7ea575a5"); ok || err == nil {
t.Fatal("缺少兼容盐时必须明确报错")
}
}
+2
View File
@@ -9,6 +9,7 @@ import (
"kra/internal/biz/system"
"kra/internal/integration/cache"
"kra/internal/integration/email"
"kra/internal/integration/gameplatform"
mqintegration "kra/internal/integration/mq"
paymentintegration "kra/internal/integration/payment"
"kra/internal/integration/sms"
@@ -22,6 +23,7 @@ import (
)
var ProviderSet = wire.NewSet(
gameplatform.NewPasswords,
cache.New,
email.NewEmailRepo,
sms.NewSender,
+4 -4
View File
@@ -25,16 +25,16 @@ func TestCatalogContainsBuiltInModulesInDependencyOrder(t *testing.T) {
t.Fatalf("definition[%d] = %q, want %q", index, got, name)
}
}
if got := catalog.MigrationSteps(); len(got) != 17 {
t.Fatalf("module migrations = %d, want 17", len(got))
if got := catalog.MigrationSteps(); len(got) != 18 {
t.Fatalf("module migrations = %d, want 18", len(got))
}
var menus, apis int
for _, definition := range catalog.Definitions {
menus += len(definition.Surface.Menus)
apis += len(definition.Surface.APIs)
}
if menus != 6 || apis != 24 {
t.Fatalf("admin surface = %d menus/%d APIs, want 6/24", menus, apis)
if menus != 6 || apis != 28 {
t.Fatalf("admin surface = %d menus/%d APIs, want 6/28", menus, apis)
}
if got := catalog.DefaultTimedTasks(); len(got) != 2 {
t.Fatalf("default timed tasks = %d, want 2", len(got))
+4
View File
@@ -41,6 +41,10 @@ type routeValue struct {
// Gin router files still bind handlers; tests keep those registrations aligned
// with this catalog while public, audit and API metadata live only here.
var routes = map[string]routeValue{
"GET /sw/business/bindPlatform": {group: "商务管理", description: "查询游戏商务绑定"},
"GET /sw/business/:userId/bindPlatform": {group: "商务管理", description: "查询指定商务已通过的绑定"},
"POST /sw/business/bindPlatform": {group: "商务管理", description: "绑定游戏商务并提交审核", audit: true},
"DELETE /sw/business/bindPlatform": {group: "商务管理", description: "解绑当前账号的游戏商务", audit: true},
"GET /sw/business/list": {group: "商务管理", description: "查询下级商务列表"},
"GET /sw/business/:userId": {group: "商务管理", description: "获取商务资料"},
"POST /sw/business": {group: "商务管理", description: "添加子商务", audit: true},
+1 -1
View File
@@ -98,7 +98,7 @@ func TestGinStartupLogsEveryRegisteredRoute(t *testing.T) {
if got, want := strings.Count(text, `"msg":"router registered"`), len(engine.Routes()); got != want {
t.Fatalf("registered route log count = %d, want %d", got, want)
}
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":203`) {
if !strings.Contains(text, `"msg":"router register success"`) || !strings.Contains(text, `"route_count":207`) {
t.Fatalf("startup route summary is missing: %s", text)
}
}
+54 -2
View File
@@ -11,10 +11,62 @@ import (
type Business struct {
preference *business.UserPreferenceService
accounts *business.AccountService
bindings *business.BindingService
}
func NewBusiness(preference *business.UserPreferenceService, accounts *business.AccountService) *Business {
return &Business{preference: preference, accounts: accounts}
func NewBusiness(preference *business.UserPreferenceService, accounts *business.AccountService, bindings *business.BindingService) *Business {
return &Business{preference: preference, accounts: accounts, bindings: bindings}
}
func (h *Business) BindingList(c *gin.Context) {
var req dto.BusinessBindingQuery
if err := c.ShouldBindQuery(&req); err != nil {
Fail(c, "绑定查询参数格式错误")
return
}
rows, err := h.bindings.List(c.Request.Context(), &req)
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, rows)
}
func (h *Business) UserBindings(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("userId"), 10, 64)
if err != nil || id == 0 {
Fail(c, "商务用户 ID 不正确")
return
}
rows, err := h.bindings.ForUser(c.Request.Context(), uint(id))
if err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OKWithData(c, rows)
}
func (h *Business) BindPlatform(c *gin.Context) {
var req dto.BusinessBindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "绑定参数格式错误")
return
}
if err := h.bindings.Bind(c.Request.Context(), &req); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
func (h *Business) UnbindPlatform(c *gin.Context) {
var req dto.BusinessBindingRequest
if err := c.ShouldBindJSON(&req); err != nil {
Fail(c, "解绑参数格式错误")
return
}
if err := h.bindings.Unbind(c.Request.Context(), &req); err != nil {
Fail(c, errors.FromError(err).Message)
return
}
OK(c)
}
func (h *Business) AccountList(c *gin.Context) {
+4
View File
@@ -6,6 +6,10 @@ import (
)
func RegisterBusiness(private *gin.RouterGroup, h *handler.Business) {
private.GET("/sw/business/bindPlatform", h.BindingList)
private.GET("/sw/business/:userId/bindPlatform", h.UserBindings)
private.POST("/sw/business/bindPlatform", h.BindPlatform)
private.DELETE("/sw/business/bindPlatform", h.UnbindPlatform)
private.GET("/sw/business/list", h.AccountList)
private.GET("/sw/business/:userId", h.Account)
private.POST("/sw/business", h.CreateAccount)
+121
View File
@@ -0,0 +1,121 @@
package business
import (
"context"
"kra/internal/biz/business"
"kra/internal/service/dto"
"strings"
"time"
"unicode/utf8"
)
type BindingUsecase interface {
List(context.Context, business.BindingFilter, ...business.ListOption) ([]*business.Binding, int64, error)
ForUser(context.Context, uint) ([]*business.Binding, error)
Bind(context.Context, string, string, string) error
Unbind(context.Context, business.PlatformKey) error
}
type BindingService struct{ uc BindingUsecase }
func NewBindingService(uc *business.BindingUsecase) *BindingService { return &BindingService{uc: uc} }
func localTime(t *time.Time) *string {
if t == nil || t.IsZero() {
return nil
}
s := t.Format("2006-01-02 15:04:05")
return &s
}
func bindingResponse(b *business.Binding) *dto.BusinessBindingResponse {
return &dto.BusinessBindingResponse{UserID: b.UserID, SourceDB: b.SourceDB, PlatformName: business.PlatformName(b.SourceDB), PlatformBusierID: b.BusierID,
Account: b.Account, UserName: b.UserName, PromoteCount: b.PromoteCount, Status: b.Status, RejectReason: b.RejectReason, CreateTime: localTime(&b.CreatedAt), ReviewTime: localTime(b.ReviewTime), ReviewAccount: b.ReviewAccount}
}
func normalizePlatforms(values []string) ([]string, error) {
result := []string{}
seen := map[string]bool{}
for _, raw := range values {
for _, value := range strings.Split(raw, ",") {
if strings.TrimSpace(value) == "" {
continue
}
value, err := business.NormalizePlatform(value)
if err != nil {
return nil, err
}
if !seen[value] {
seen[value] = true
result = append(result, value)
}
}
}
return result, nil
}
func (s *BindingService) List(ctx context.Context, req *dto.BusinessBindingQuery) (*dto.BusinessBindingPage, error) {
if req == nil {
return nil, business.ErrAccountInput
}
sources, err := normalizePlatforms(req.SourceDB)
if err != nil {
return nil, err
}
if req.Status != "" && req.Status != "0" && req.Status != "2" && req.Status != "3" {
return nil, business.ErrAccountInput
}
page, size := req.Page, req.PageSize
if page < 1 {
page = 1
}
if size < 1 {
size = 10
}
if size > 500 {
size = 500
}
if page > int(^uint(0)>>1)/size {
return nil, business.ErrAccountInput
}
rows, total, err := s.uc.List(ctx, business.BindingFilter{SourceDBs: sources, Account: req.Account, Status: req.Status}, business.ListOffset((page-1)*size), business.ListLimit(size))
if err != nil {
return nil, err
}
out := &dto.BusinessBindingPage{Rows: make([]*dto.BusinessBindingResponse, 0, len(rows)), Total: total}
for _, b := range rows {
out.Rows = append(out.Rows, bindingResponse(b))
}
return out, nil
}
func (s *BindingService) ForUser(ctx context.Context, id uint) ([]*dto.BusinessBindingResponse, error) {
if id == 0 {
return nil, business.ErrAccountInput
}
rows, err := s.uc.ForUser(ctx, id)
if err != nil {
return nil, err
}
out := make([]*dto.BusinessBindingResponse, 0, len(rows))
for _, b := range rows {
out = append(out, bindingResponse(b))
}
return out, nil
}
func (s *BindingService) Bind(ctx context.Context, req *dto.BusinessBindingRequest) error {
if req == nil || strings.TrimSpace(req.Account) == "" || utf8.RuneCountInString(req.Account) > 30 || strings.TrimSpace(req.Password) == "" || utf8.RuneCountInString(req.Password) > 100 {
return business.ErrAccountInput
}
source, err := business.NormalizePlatform(req.SourceDB)
if err != nil {
return err
}
return s.uc.Bind(ctx, source, req.Account, req.Password)
}
func (s *BindingService) Unbind(ctx context.Context, req *dto.BusinessBindingRequest) error {
if req == nil || req.PlatformBusierID == 0 {
return business.ErrAccountInput
}
source, err := business.NormalizePlatform(req.SourceDB)
if err != nil {
return err
}
return s.uc.Unbind(ctx, business.PlatformKey{SourceDB: source, BusierID: req.PlatformBusierID})
}
+33
View File
@@ -0,0 +1,33 @@
package dto
type BusinessBindingQuery struct {
Page int `form:"pageNum"`
PageSize int `form:"pageSize"`
SourceDB []string `form:"sourceDb"`
Account string `form:"platformBusierAccount"`
Status string `form:"status"`
}
type BusinessBindingRequest struct {
SourceDB string `json:"sourceDb"`
Account string `json:"account"`
Password string `json:"password"`
PlatformBusierID uint64 `json:"platformBusierId"`
}
type BusinessBindingResponse struct {
UserID uint `json:"userId"`
SourceDB string `json:"sourceDb"`
PlatformName string `json:"platformName"`
PlatformBusierID uint64 `json:"platformBusierId"`
Account string `json:"platformBusierAccount"`
UserName string `json:"bindUserName"`
PromoteCount *int64 `json:"promoteCount"`
Status string `json:"status"`
RejectReason *string `json:"rejectReason"`
CreateTime *string `json:"createTime"`
ReviewTime *string `json:"reviewTime"`
ReviewAccount *string `json:"reviewAccount"`
}
type BusinessBindingPage struct {
Rows []*BusinessBindingResponse `json:"rows"`
Total int64 `json:"total"`
}
+1
View File
@@ -14,6 +14,7 @@ import (
var ProviderSet = wire.NewSet(
businessservice.NewUserPreferenceService,
businessservice.NewAccountService,
businessservice.NewBindingService,
systemservice.ProviderSet,
paymentservice.ProviderSet,
integrationservice.ProviderSet,