diff --git a/cmd/business_http_test.go b/cmd/business_http_test.go new file mode 100644 index 0000000..4e4d5d0 --- /dev/null +++ b/cmd/business_http_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + businessbiz "kra/internal/biz/business" + "kra/internal/biz/system" + businessdata "kra/internal/data/business" + systemdata "kra/internal/data/system" + "kra/internal/server/handler" + "kra/internal/server/router" + businessservice "kra/internal/service/business" +) + +type businessHTTPData struct{ db *gorm.DB } + +func (d businessHTTPData) DB() *gorm.DB { return d.db } + +func TestBusinessHTTPListWithSystemOnlyAndCompleteDatabase(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "http.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + sqlDB, err := db.DB() + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = sqlDB.Close() }) + for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + 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_user_bind_platform(user_id,source_db,platform_busier_id,status) VALUES (2,'lx',9,'0')", + } { + if err := db.Exec(statement).Error; err != nil { + t.Fatal(err) + } + } + provider := businessHTTPData{db} + accounts := businessservice.NewAccountService(businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))) + 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})) + c.Next() + }) + router.RegisterBusiness(engine.Group(""), handler.NewBusiness(prefs, accounts)) + server := httptest.NewServer(engine) + defer server.Close() + requestList := func() map[string]any { + t.Helper() + req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/sw/business/list?pageNum=1&pageSize=10", nil) + response, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer response.Body.Close() + var body map[string]any + if err := json.NewDecoder(response.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if response.StatusCode != 200 || body["code"] != float64(0) { + t.Fatalf("真实 HTTP 列表请求失败: %+v", body) + } + return body["data"].(map[string]any) + } + data := requestList() + rows := data["rows"].([]any) + if data["total"] != float64(3) || len(rows) != 3 { + t.Fatalf("系统数据未完整返回: %+v", data) + } + child := rows[1].(map[string]any) + if child["promoteCount"] != nil || child["bindCount"] != float64(1) { + t.Fatalf("未接入统计不能伪装为零: %+v", child) + } + if rows[2].(map[string]any)["promoteCount"] != float64(0) { + t.Fatal("无绑定账号的直属渠道应明确为零") + } + if err := db.Exec("CREATE TABLE tab_promote(id INTEGER,source_db TEXT,busier_id INTEGER,promote_level INTEGER)").Error; err != nil { + t.Fatal(err) + } + if err := db.Exec("INSERT INTO tab_promote VALUES(1,'lx',9,1),(2,'lx',9,2),(1,'hw',9,1)").Error; err != nil { + t.Fatal(err) + } + data = requestList() + child = data["rows"].([]any)[1].(map[string]any) + if child["promoteCount"] != float64(1) { + t.Fatalf("完整数据应恢复真实且按平台隔离的渠道数: %+v", child) + } +} diff --git a/docs/sw6-to-kra/http-verification-2026-11-03.json b/docs/sw6-to-kra/http-verification-2026-11-03.json new file mode 100644 index 0000000..24c44b5 --- /dev/null +++ b/docs/sw6-to-kra/http-verification-2026-11-03.json @@ -0,0 +1,64 @@ +{ + "date": "2026-11-03", + "timezone": "Asia/Shanghai", + "base_url": "http://127.0.0.1:18000", + "database_scope": "沿用已配置的 sw7 系统迁移测试库;未复制或迁移旧正式数据库", + "authentication": "通过本地服务验证码及账号密码正常登录后,携带 JWT 请求;凭据和令牌不记录在报告中", + "observed_failure": "原 8000 实例日志:Error 1146,sw7.tab_promote 不存在导致商务列表返回商务数据访问失败", + "fix": "基础商务资料和已通过绑定数继续返回;有绑定但渠道表未接入时 promoteCount 为 null;完整业务库仍按原口径计算真实渠道数", + "checks": [ + { + "path": "/health", + "http": 200, + "elapsed_ms": 64 + }, + { + "path": "/sw/business/list?pageNum=1&pageSize=10", + "http": 200, + "elapsed_ms": 36, + "code": 0, + "msg": "成功", + "total": 45, + "rows": 10, + "unavailable_promote_counts": 1 + }, + { + "path": "/sw/business/list?pageNum=2&pageSize=10", + "http": 200, + "elapsed_ms": 46, + "code": 0, + "msg": "成功", + "total": 45, + "rows": 10, + "unavailable_promote_counts": 5 + }, + { + "path": "/sw/business/list?userName=admin&pageNum=1&pageSize=10", + "http": 200, + "elapsed_ms": 32, + "code": 0, + "msg": "成功", + "total": 1, + "rows": 1, + "unavailable_promote_counts": 0 + }, + { + "path": "/sw/business/10001", + "http": 200, + "elapsed_ms": 33, + "code": 0, + "msg": "成功" + }, + { + "path": "/sw/user/preference", + "http": 200, + "elapsed_ms": 103, + "code": 0, + "msg": "成功" + } + ], + "limits": [ + "验证实例与 GoLand 的 8000 进程独立;GoLand 必须重新运行才能使用新代码", + "本次未验证完整渠道数据,完整旧库复制和最终一致性核对仍待迁移结束后确认" + ] +} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 39f4f0d..c8df480 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -11,6 +11,8 @@ 沿用现有迁移 SQL 的 `/sw/` 路由与 `view/sw/` 菜单组件路径。认证、响应信封、权限、表格及图表采用 Kra;业务字段、统计口径、状态流转、可见范围保留旧实现。登录页、系统名称和品牌资源来自旧商务。新增代码注释与提交信息使用中文。 +用户补充:允许直接优化旧接口、SQL 和逻辑。优化时记录行为差异并验证业务结果;各批实现必须实际请求 HTTP 接口,不能只以单元测试和构建作为完成依据。 + 用户已有的 `configs/config.yaml` 修改与迁移 SQL 保留。未经最后阶段的数据库确认,不连接、复制或修改用户的旧数据库。 ## 依赖顺序与完成标准 @@ -47,4 +49,6 @@ - 已生成 `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 旧二进制,需重新运行才加载源码修复。 - 当前没有执行本轮真实数据库迁移验证。 diff --git a/internal/biz/business/account.go b/internal/biz/business/account.go index 8d2357e..0873a92 100644 --- a/internal/biz/business/account.go +++ b/internal/biz/business/account.go @@ -32,14 +32,21 @@ type Account struct { RejectReason string AuthorityID uint CreatedAt, UpdatedAt time.Time - PromoteCount, BindCount int64 + PromoteCount *int64 + BindCount int64 +} + +type AccountCounts struct { + // 无渠道表时为 nil,避免将未接入数据误报为真实零渠道。 + PromoteCount *int64 + BindCount int64 } // UserScope 表示商务树可见范围,与框架部门权限相互独立。 type UserScope struct { - All, Empty, IncludeSelf bool - SelfID uint - DescendantPath string + All, Empty, IncludeSelf, OnlySelf bool + SelfID uint + DescendantPath string } type AccountFilter struct { @@ -84,7 +91,7 @@ type AccountRepo interface { Create(context.Context, *Account) error Update(context.Context, *Account) error AuthorityForLevel(context.Context, int) (uint, error) - DirectCounts(context.Context, []uint) (map[uint][2]int64, error) + DirectCounts(context.Context, []uint) (map[uint]AccountCounts, error) } type AccountUsecase struct{ repo AccountRepo } @@ -141,7 +148,7 @@ func (uc *AccountUsecase) List(ctx context.Context, filter AccountFilter, option return nil, 0, err } for _, row := range rows { - row.PromoteCount, row.BindCount = counts[row.ID][0], counts[row.ID][1] + row.PromoteCount, row.BindCount = counts[row.ID].PromoteCount, counts[row.ID].BindCount } return rows, total, nil } diff --git a/internal/data/business/account.go b/internal/data/business/account.go index f755110..1f22c4e 100644 --- a/internal/data/business/account.go +++ b/internal/data/business/account.go @@ -71,6 +71,9 @@ func applyUserScope(db *gorm.DB, scope business.UserScope, columnPrefix string) return db.Where("1=0") } id, path, parent := columnPrefix+"id", columnPrefix+"id_path", columnPrefix+"parent_id" + if scope.OnlySelf { + return db.Where(id+" = ?", scope.SelfID) + } query := "(" + path + " = ? OR " + path + " LIKE ? OR " + parent + " = ?)" args := []any{scope.DescendantPath, scope.DescendantPath + ",%", scope.SelfID} if scope.IncludeSelf { @@ -222,12 +225,16 @@ func (r *accountRepo) AuthorityForLevel(ctx context.Context, level int) (uint, e return ids[0], nil } -func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint][2]int64, error) { - result := map[uint][2]int64{} +func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint]business.AccountCounts, error) { + result := map[uint]business.AccountCounts{} if len(ids) == 0 { return result, nil } db := r.data.DB().WithContext(ctx) + for _, id := range ids { + zero := int64(0) + result[id] = business.AccountCounts{PromoteCount: &zero} + } var binds []struct { UserID uint Total int64 @@ -239,7 +246,28 @@ func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint][2 return result, nil } for _, b := range binds { - result[b.UserID] = [2]int64{0, b.Total} + zero := int64(0) + result[b.UserID] = business.AccountCounts{PromoteCount: &zero, BindCount: b.Total} + } + // 系统数据演练库尚未包含渠道表时,基础资料与已通过绑定数仍可使用。 + tables, err := db.Migrator().GetTables() + 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] + value.PromoteCount = nil + result[b.UserID] = value + } + return result, nil } var counts []struct { UserID uint @@ -250,7 +278,8 @@ func (r *accountRepo) DirectCounts(ctx context.Context, ids []uint) (map[uint][2 } for _, c := range counts { value := result[c.UserID] - value[0] = c.Total + count := c.Total + value.PromoteCount = &count result[c.UserID] = value } return result, nil diff --git a/internal/data/business/account_test.go b/internal/data/business/account_test.go index bd28893..e368242 100644 --- a/internal/data/business/account_test.go +++ b/internal/data/business/account_test.go @@ -95,7 +95,7 @@ func TestAccountListScopesAndCrossPlatformCounts(t *testing.T) { if err != nil { t.Fatal(err) } - if counts[13] != [2]int64{3, 2} || counts[14] != [2]int64{1, 1} || counts[99] != [2]int64{} { + if counts[13].PromoteCount == nil || *counts[13].PromoteCount != 3 || counts[13].BindCount != 2 || counts[14].PromoteCount == nil || *counts[14].PromoteCount != 1 || counts[14].BindCount != 1 || counts[99].PromoteCount == nil || *counts[99].PromoteCount != 0 { t.Fatalf("渠道数或绑定数口径不一致: %+v", counts) } rows, total, err = r.List(context.Background(), business.UserScope{}, business.ListFilter(business.AccountFilter{})) diff --git a/internal/service/dto/business_account.go b/internal/service/dto/business_account.go index 0d19849..8f80084 100644 --- a/internal/service/dto/business_account.go +++ b/internal/service/dto/business_account.go @@ -46,7 +46,7 @@ type BusinessAccountResponse struct { AuthorityID uint `json:"authorityId"` CreateTime string `json:"createTime"` UpdateTime string `json:"updateTime"` - PromoteCount int64 `json:"promoteCount"` + PromoteCount *int64 `json:"promoteCount"` BindCount int64 `json:"bindCount"` } diff --git a/web/src/view/sw/BusinessList.vue b/web/src/view/sw/BusinessList.vue index a5dd033..c67a0d8 100644 --- a/web/src/view/sw/BusinessList.vue +++ b/web/src/view/sw/BusinessList.vue @@ -28,7 +28,12 @@ - + + +