diff --git a/cmd/business_http_test.go b/cmd/business_http_test.go index 4e4d5d0..e013769 100644 --- a/cmd/business_http_test.go +++ b/cmd/business_http_test.go @@ -6,6 +6,7 @@ import ( "net/http" "net/http/httptest" "path/filepath" + "strings" "testing" "github.com/gin-gonic/gin" @@ -24,6 +25,16 @@ type businessHTTPData struct{ db *gorm.DB } func (d businessHTTPData) DB() *gorm.DB { return d.db } +type httpPlatformAccounts struct{} + +func (httpPlatformAccounts) FindByAccount(context.Context, string, string) (*businessbiz.PlatformAccount, error) { + return &businessbiz.PlatformAccount{ID: 77, Status: 1}, nil +} + +type httpPlatformPasswords struct{} + +func (httpPlatformPasswords) Verify(string, string) (bool, error) { return true, nil } + func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "http.db")), &gorm.Config{}) if err != nil { @@ -40,7 +51,8 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { } } for _, statement := range []string{ - "INSERT INTO sys_users(id,uuid,username,authority_id,level,id_path) VALUES (1,'admin','admin',888,0,'0'),(2,'child','child',901,1,'0'),(3,'empty','empty',901,1,'0')", + "INSERT INTO sys_users(id,uuid,username,authority_id,level,id_path) VALUES (1,'admin','admin',888,0,'0'),(2,'child','child',901,1,'0'),(3,'empty','empty',903,3,'0,2')", + "INSERT INTO sys_authorities(authority_id,authority_name) VALUES (901,'一级商务')", "INSERT INTO sys_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES (2,'lx',9,'0')", } { if err := db.Exec(statement).Error; err != nil { @@ -48,15 +60,21 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { } } provider := businessHTTPData{db} - accounts := businessservice.NewAccountService(businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))) + accountUsecase := businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider)) + accounts := businessservice.NewAccountService(accountUsecase) + bindings := businessservice.NewBindingService(businessbiz.NewBindingUsecase(businessdata.NewBindingRepo(provider), accountUsecase, httpPlatformAccounts{}, httpPlatformPasswords{})) prefs := businessservice.NewUserPreferenceService(businessbiz.NewUserPreferenceUsecase(businessdata.NewUserPreferenceRepo(provider))) engine := gin.New() // 此契约测试固定已认证身份;真实登录另通过运行实例验证。 engine.Use(func(c *gin.Context) { - c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: 1, AuthorityID: 888})) + actor := system.Actor{UserID: 1, AuthorityID: 888} + if c.GetHeader("X-Test-Actor") == "3" { + actor = system.Actor{UserID: 3, AuthorityID: 903} + } + c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), actor)) c.Next() }) - router.RegisterBusiness(engine.Group(""), handler.NewBusiness(prefs, accounts)) + router.RegisterBusiness(engine.Group(""), handler.NewBusiness(prefs, accounts, bindings)) server := httptest.NewServer(engine) defer server.Close() requestList := func() map[string]any { @@ -99,4 +117,47 @@ func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { if child["promoteCount"] != float64(1) { t.Fatalf("完整数据应恢复真实且按平台隔离的渠道数: %+v", child) } + call := func(method, path, body, actor string) map[string]any { + t.Helper() + req, _ := http.NewRequest(method, server.URL+path, strings.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Test-Actor", actor) + response, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var value map[string]any + if err := json.NewDecoder(response.Body).Decode(&value); err != nil { + t.Fatal(err) + } + if value["code"] != float64(0) { + t.Fatalf("%s %s 失败: %+v", method, path, value) + } + return value + } + created := call("POST", "/sw/business", `{"userName":"http_child","realName":"接口测试","password":"abc123"}`, "")["data"].(map[string]any) + if created["userId"] != float64(4) || created["level"] != float64(1) || created["authorityId"] != float64(901) { + t.Fatalf("HTTP 新增层级或角色不正确: %+v", created) + } + call("PUT", "/sw/business", `{"userId":4,"realName":"更新姓名"}`, "") + updated := call("GET", "/sw/business/4", "", "")["data"].(map[string]any) + if updated["realName"] != "更新姓名" || updated["status"] != "0" { + t.Fatalf("HTTP 部分编辑不正确: %+v", updated) + } + call("PUT", "/sw/user/preference", `{"includeSubPromote":false,"idcardDedup":false}`, "") + preference := call("GET", "/sw/user/preference", "", "")["data"].(map[string]any) + if preference["includeSubPromote"] != false || preference["idcardDedup"] != false { + t.Fatalf("HTTP 偏好关闭未保存: %+v", preference) + } + call("POST", "/sw/business/bindPlatform", `{"sourceDb":"lx","account":"sdk-test","password":"test-only"}`, "3") + bound := call("GET", "/sw/business/bindPlatform", "", "3")["data"].(map[string]any) + if bound["total"] != float64(1) || bound["rows"].([]any)[0].(map[string]any)["status"] != "3" { + t.Fatalf("HTTP 绑定未进入待审核: %+v", bound) + } + call("DELETE", "/sw/business/bindPlatform", `{"sourceDb":"lx","platformBusierId":77}`, "3") + bound = call("GET", "/sw/business/bindPlatform", "", "3")["data"].(map[string]any) + if bound["total"] != float64(0) { + t.Fatalf("HTTP 解绑未删除当前账号绑定: %+v", bound) + } } diff --git a/cmd/wire_gen.go b/cmd/wire_gen.go index 2e4a094..889ce1e 100644 --- a/cmd/wire_gen.go +++ b/cmd/wire_gen.go @@ -24,6 +24,7 @@ import ( integration4 "kra/internal/integration" "kra/internal/integration/cache" "kra/internal/integration/email" + "kra/internal/integration/gameplatform" "kra/internal/integration/mq" "kra/internal/integration/payment" "kra/internal/integration/sms" @@ -87,7 +88,12 @@ func wireApp(configServer *config.Server, store *config.Store, logger *slog.Logg accountRepo := business.NewAccountRepo(dataData) accountUsecase := business2.NewAccountUsecase(accountRepo) accountService := business3.NewAccountService(accountUsecase) - handlerBusiness := handler.NewBusiness(userPreferenceService, accountService) + bindingRepo := business.NewBindingRepo(dataData) + platformAccountRepo := business.NewPlatformAccountRepo(dataData) + platformPasswordVerifier := gameplatform.NewPasswords() + bindingUsecase := business2.NewBindingUsecase(bindingRepo, accountUsecase, platformAccountRepo, platformPasswordVerifier) + bindingService := business3.NewBindingService(bindingUsecase) + handlerBusiness := handler.NewBusiness(userPreferenceService, accountService, bindingService) runtimeconfigStore := data.NewIntegrationRuntime(dataData) smsSender := sms.NewSender(runtimeconfigStore) smsUsecase := integration.NewSMSUsecase(smsSender) diff --git a/docs/sw6-to-kra/http-business-bindings-2026-11-03.json b/docs/sw6-to-kra/http-business-bindings-2026-11-03.json new file mode 100644 index 0000000..2b1e5df --- /dev/null +++ b/docs/sw6-to-kra/http-business-bindings-2026-11-03.json @@ -0,0 +1,133 @@ +{ + "base_url": "http://127.0.0.1:18000", + "checks": [ + { + "method": "GET", + "path": "/sw/business/list?pageNum=1&pageSize=500", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 184, + "passed": true, + "total": 45, + "rows": 45 + }, + { + "method": "GET", + "path": "/sw/business/list?pageNum=2&pageSize=10", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 43, + "passed": true, + "total": 45, + "rows": 10 + }, + { + "method": "GET", + "path": "/sw/business/10001", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 19, + "passed": true + }, + { + "method": "GET", + "path": "/sw/business/list?userName=admin", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 22, + "passed": true, + "total": 1, + "rows": 1 + }, + { + "method": "GET", + "path": "/sw/business/10007/bindPlatform", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 25, + "passed": true + }, + { + "method": "GET", + "path": "/sw/user/preference", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 94, + "passed": true + }, + { + "method": "GET", + "path": "/sw/business/bindPlatform?pageNum=1&pageSize=10", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 22, + "passed": true, + "total": 58, + "rows": 10 + }, + { + "method": "GET", + "path": "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2", + "http": 200, + "code": 0, + "msg": "成功", + "elapsed_ms": 23, + "passed": true, + "total": 37, + "rows": 2 + }, + { + "method": "PUT", + "path": "/sw/user/preference", + "http": 200, + "code": 7, + "msg": "偏好参数不能为空", + "elapsed_ms": 424, + "passed": true + }, + { + "method": "POST", + "path": "/sw/business", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 471, + "passed": true + }, + { + "method": "PUT", + "path": "/sw/business", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 335, + "passed": true + }, + { + "method": "POST", + "path": "/sw/business/bindPlatform", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 521, + "passed": true + }, + { + "method": "DELETE", + "path": "/sw/business/bindPlatform", + "http": 200, + "code": 7, + "msg": "商务账号参数不正确", + "elapsed_ms": 272, + "passed": true + } + ], + "passed": true +} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index c8df480..c9835a9 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -45,10 +45,13 @@ - 已落地商务用户七个扩展字段的存取及增量补列;登录和既有会话同时检查商务审核状态。 - 已落地全局偏好 GET/PUT、当前用户隔离、缺省开启、显式关闭及幂等写入;Kra 顶栏增加统计口径入口。 - 已落地商务列表、查询资料、新增子商务、部分编辑和审核状态,以及 Kra 风格 `BusinessList` 页面。层级及角色由服务端按原角色 ID 映射分配;账号、角色、偏好在同一事务创建。列表按平台与商务组合键统计直属一级渠道和已通过绑定。 -- 商务列表的绑定、画像、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。 +- 商务画像、旗下渠道明细、导出等功能仍在后续范围内;当前不将整个商务菜单标为完成。 +- 已接入绑定列表、指定商务已通过绑定详情、提交绑定及解绑四个接口,页面增加游戏 SDK 绑定页签与详情抽屉。绑定流程保持三级限制、平台验密、OA 待审核及驳回重提规则;新增键锁表 `sys_business_binding_locks`,串行处理同一平台商务的并发绑定。 +- 平台数据源通过 Kra 的 `data.database_list` 复用连接,别名为 `lx` / `hw` / `yq`。旧密码算法支持三种格式,兼容盐通过环境变量 `SW_SDK_AUTH_CODE`、`SW_SDK_MIGRATE_AUTH_CODE` 提供,取值应与旧 `XiguPasswordUtils` 对应常量一致;`SW_SDK_TABLE_PREFIX` 缺省为 `sys_`。真实连接凭据和盐不写进新仓库。完整平台库验密仍需部署配置后联调。 - 已生成 `source-inventory.json`:现有 SQL 的 63 个接口契约、279 个变更源文件及额外找回密码控制器。此清单是范围索引,不代表已完成实现。 - 已执行 `make generate`、相关 Go 测试、变更前端 ESLint 和生产构建。构建存在原 `arcdash` 依赖的 BigInt 浏览器目标警告。 - 第一批提交 `ce4b54a` 后 `go test ./...` 全量通过;第二批账号功能的 biz/data/service/modules/server/cmd 检查及前端生产构建通过。 - 用户反馈实际商务列表失败后,定位为 sw7 缺少 `tab_promote`。已解耦基础列表与渠道统计:有绑定且渠道表缺失时返回 `promoteCount: null`,界面显示“未接入”,无绑定账号仍为真实零。新增通过真实 TCP 请求的 HTTP 回归测试,覆盖系统数据库及完整渠道表两种场景。 - 已通过正常验证码登录取得测试会话,并在修复后的独立 18000 实例连接同一 sw7 执行 HTTP 请求。列表总数 45,分页、账号筛选、资料和偏好查询全部成功。详见 `http-verification-2026-11-03.json`。8000 为用户 GoLand 旧二进制,需重新运行才加载源码修复。 +- 绑定批次通过 13 项真实 HTTP 检查:商务 45 条、绑定 58 条、平台与审核筛选、详情、偏好查询和写请求校验,结果见 `http-business-bindings-2026-11-03.json`;`scripts/sw_http_smoke.py` 可复用且不记录令牌。真实库不制造测试商务或发起实际平台绑定。新增、修改、偏好关闭、提交绑定及解绑的正向流程已用独立 SQLite 数据库通过实际 TCP HTTP 测试;平台验密外部边界使用测试替身,密码算法另用固定向量验证。 - 当前没有执行本轮真实数据库迁移验证。 diff --git a/internal/biz/biz.go b/internal/biz/biz.go index ffc7fdb..626555e 100644 --- a/internal/biz/biz.go +++ b/internal/biz/biz.go @@ -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) diff --git a/internal/biz/business/binding.go b/internal/biz/business/binding.go new file mode 100644 index 0000000..463b9c1 --- /dev/null +++ b/internal/biz/business/binding.go @@ -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) }) +} diff --git a/internal/biz/business/binding_test.go b/internal/biz/business/binding_test.go new file mode 100644 index 0000000..b368693 --- /dev/null +++ b/internal/biz/business/binding_test.go @@ -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) + } +} diff --git a/internal/data/business/account.go b/internal/data/business/account.go index 1f22c4e..b6ed321 100644 --- a/internal/data/business/account.go +++ b/internal/data/business/account.go @@ -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] diff --git a/internal/data/business/binding_repo.go b/internal/data/business/binding_repo.go new file mode 100644 index 0000000..c0400fe --- /dev/null +++ b/internal/data/business/binding_repo.go @@ -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 +} diff --git a/internal/data/business/binding_test.go b/internal/data/business/binding_test.go new file mode 100644 index 0000000..2abd4b8 --- /dev/null +++ b/internal/data/business/binding_test.go @@ -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) + } +} diff --git a/internal/data/business/migrations.go b/internal/data/business/migrations.go index 64318b3..ff0d0b2 100644 --- a/internal/data/business/migrations.go +++ b/internal/data/business/migrations.go @@ -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 { // 旧表沿用;已迁移数据库不重建或重置用户偏好。 diff --git a/internal/data/business/platform.go b/internal/data/business/platform.go new file mode 100644 index 0000000..7999553 --- /dev/null +++ b/internal/data/business/platform.go @@ -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 +} diff --git a/internal/data/business/schema.go b/internal/data/business/schema.go new file mode 100644 index 0000000..fbdbc07 --- /dev/null +++ b/internal/data/business/schema.go @@ -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 +} diff --git a/internal/data/data.go b/internal/data/data.go index 06b32a0..92a7c99 100644 --- a/internal/data/data.go +++ b/internal/data/data.go @@ -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)), diff --git a/internal/data/migrations_test.go b/internal/data/migrations_test.go index 74e1cfe..998e037 100644 --- a/internal/data/migrations_test.go +++ b/internal/data/migrations_test.go @@ -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 { diff --git a/internal/integration/gameplatform/password.go b/internal/integration/gameplatform/password.go new file mode 100644 index 0000000..ebc03b8 --- /dev/null +++ b/internal/integration/gameplatform/password.go @@ -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 +} diff --git a/internal/integration/gameplatform/password_test.go b/internal/integration/gameplatform/password_test.go new file mode 100644 index 0000000..73328bc --- /dev/null +++ b/internal/integration/gameplatform/password_test.go @@ -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("缺少兼容盐时必须明确报错") + } +} diff --git a/internal/integration/provider.go b/internal/integration/provider.go index caadd4b..5d5a331 100644 --- a/internal/integration/provider.go +++ b/internal/integration/provider.go @@ -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, diff --git a/internal/modules/catalog_test.go b/internal/modules/catalog_test.go index 6939497..a0fd945 100644 --- a/internal/modules/catalog_test.go +++ b/internal/modules/catalog_test.go @@ -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)) diff --git a/internal/routecatalog/catalog.go b/internal/routecatalog/catalog.go index 221d7fb..be93d45 100644 --- a/internal/routecatalog/catalog.go +++ b/internal/routecatalog/catalog.go @@ -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}, diff --git a/internal/server/gin_test.go b/internal/server/gin_test.go index b41b81f..e9e8ae1 100644 --- a/internal/server/gin_test.go +++ b/internal/server/gin_test.go @@ -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) } } diff --git a/internal/server/handler/business.go b/internal/server/handler/business.go index e669524..c4a87aa 100644 --- a/internal/server/handler/business.go +++ b/internal/server/handler/business.go @@ -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) { diff --git a/internal/server/router/business.go b/internal/server/router/business.go index 4649a9a..77cb489 100644 --- a/internal/server/router/business.go +++ b/internal/server/router/business.go @@ -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) diff --git a/internal/service/business/binding.go b/internal/service/business/binding.go new file mode 100644 index 0000000..e93ea7a --- /dev/null +++ b/internal/service/business/binding.go @@ -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}) +} diff --git a/internal/service/dto/business_binding.go b/internal/service/dto/business_binding.go new file mode 100644 index 0000000..324d084 --- /dev/null +++ b/internal/service/dto/business_binding.go @@ -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"` +} diff --git a/internal/service/service.go b/internal/service/service.go index 99f6d50..6b8fc6b 100644 --- a/internal/service/service.go +++ b/internal/service/service.go @@ -14,6 +14,7 @@ import ( var ProviderSet = wire.NewSet( businessservice.NewUserPreferenceService, businessservice.NewAccountService, + businessservice.NewBindingService, systemservice.ProviderSet, paymentservice.ProviderSet, integrationservice.ProviderSet, diff --git a/scripts/sw_http_smoke.py b/scripts/sw_http_smoke.py new file mode 100644 index 0000000..e060252 --- /dev/null +++ b/scripts/sw_http_smoke.py @@ -0,0 +1,69 @@ +"""请求运行中的商务接口并生成脱敏结果;令牌从仓库外文件读取。""" + +import argparse +import json +import time +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--base-url", default="http://127.0.0.1:18000") + parser.add_argument("--token-file", required=True, type=Path) + parser.add_argument("--output", required=True, type=Path) + args = parser.parse_args() + token = args.token_file.read_text(encoding="utf-8").strip() + results = [] + + def request(method, path, payload=None, expected=0): + started = time.monotonic() + req = urllib.request.Request( + args.base_url.rstrip("/") + path, + data=json.dumps(payload).encode() if payload is not None else None, + headers={"x-token": token, "Content-Type": "application/json"}, method=method) + try: + with urllib.request.urlopen(req, timeout=30) as response: + body = json.load(response) + item = {"method": method, "path": path, "http": response.status, + "code": body.get("code"), "msg": body.get("msg"), + "elapsed_ms": round((time.monotonic() - started) * 1000)} + item["passed"] = response.status == 200 and body.get("code") == expected + data = body.get("data") + if isinstance(data, dict) and "rows" in data: + item.update(total=data.get("total"), rows=len(data["rows"])) + results.append(item) + print(json.dumps(item, ensure_ascii=False), flush=True) + return data if item["passed"] else None + except (urllib.error.URLError, TimeoutError, ValueError) as error: + item = {"method": method, "path": path, "passed": False, "error": str(error)} + results.append(item) + print(json.dumps(item, ensure_ascii=False), flush=True) + return None + + first = request("GET", "/sw/business/list?pageNum=1&pageSize=500") + request("GET", "/sw/business/list?pageNum=2&pageSize=10") + if first and first["rows"]: + account = first["rows"][0] + request("GET", f"/sw/business/{account['userId']}") + request("GET", "/sw/business/list?" + urllib.parse.urlencode({"userName": account["userName"]})) + bound = next((row for row in first["rows"] if row["bindCount"] > 0), account) + request("GET", f"/sw/business/{bound['userId']}/bindPlatform") + request("GET", "/sw/user/preference") + request("GET", "/sw/business/bindPlatform?pageNum=1&pageSize=10") + request("GET", "/sw/business/bindPlatform?sourceDb=lx,hw&status=0&pageSize=2") + # 使用校验必然拒绝的请求体检查写接口,不新增或修改测试库业务记录。 + for method, path in [("PUT", "/sw/user/preference"), ("POST", "/sw/business"), + ("PUT", "/sw/business"), ("POST", "/sw/business/bindPlatform"), + ("DELETE", "/sw/business/bindPlatform")]: + request(method, path, {}, expected=7) + args.output.write_text(json.dumps({"base_url": args.base_url, "checks": results, + "passed": all(row["passed"] for row in results)}, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + if not all(row["passed"] for row in results): + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/web/src/api/business.js b/web/src/api/business.js index 0aae0cf..461cb8c 100644 --- a/web/src/api/business.js +++ b/web/src/api/business.js @@ -7,3 +7,8 @@ export const listBusiness = (params) => service({ url: '/sw/business/list', meth export const getBusiness = (id) => service({ url: `/sw/business/${id}`, method: 'get' }) export const createBusiness = (data) => service({ url: '/sw/business', method: 'post', data }) export const updateBusiness = (data) => service({ url: '/sw/business', method: 'put', data }) + +export const listBusinessBindings = (params) => service({ url: '/sw/business/bindPlatform', method: 'get', params }) +export const listUserBindings = (id) => service({ url: `/sw/business/${id}/bindPlatform`, method: 'get' }) +export const bindPlatform = (data) => service({ url: '/sw/business/bindPlatform', method: 'post', data }) +export const unbindPlatform = (data) => service({ url: '/sw/business/bindPlatform', method: 'delete', data }) diff --git a/web/src/pathInfo.json b/web/src/pathInfo.json index 5509588..a0909b4 100644 --- a/web/src/pathInfo.json +++ b/web/src/pathInfo.json @@ -53,6 +53,7 @@ "/src/view/superAdmin/position/position.vue": "Position", "/src/view/superAdmin/user/user.vue": "User", "/src/view/sw/BusinessList.vue": "SwBusinessList", + "/src/view/sw/components/BusinessBindings.vue": "SwBusinessBindings", "/src/view/system/security/forceChangePassword.vue": "ForceChangePassword", "/src/view/system/security/index.vue": "SecurityConfig", "/src/view/system/state.vue": "State", diff --git a/web/src/view/sw/BusinessList.vue b/web/src/view/sw/BusinessList.vue index c67a0d8..9a7f1fc 100644 --- a/web/src/view/sw/BusinessList.vue +++ b/web/src/view/sw/BusinessList.vue @@ -1,6 +1,8 @@