diff --git a/cmd/user_business_status_http_test.go b/cmd/user_business_status_http_test.go new file mode 100644 index 0000000..42d1f32 --- /dev/null +++ b/cmd/user_business_status_http_test.go @@ -0,0 +1,123 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "path/filepath" + "strconv" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/glebarez/sqlite" + "gorm.io/gorm" + businessbiz "kra/internal/biz/business" + "kra/internal/biz/system" + "kra/internal/config" + businessdata "kra/internal/data/business" + systemdata "kra/internal/data/system" + "kra/internal/server/handler" + "kra/internal/server/router" + businessservice "kra/internal/service/business" + systemservice "kra/internal/service/system" +) + +type nativeUserHTTPData struct { + businessHTTPData + runtime *config.Store +} + +func (d nativeUserHTTPData) Database(name string) (*gorm.DB, error) { + if name == "" { + return d.db, nil + } + return nil, fmt.Errorf("未配置") +} +func (d nativeUserHTTPData) DatabaseReady() bool { return true } +func (d nativeUserHTTPData) Runtime() *config.Store { return d.runtime } + +func TestNativeUserEnableAndBusinessStatusOverHTTP(t *testing.T) { + db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "native-status.db")), &gorm.Config{}) + if err != nil { + t.Fatal(err) + } + pool, _ := db.DB() + t.Cleanup(func() { _ = pool.Close() }) + for _, step := range append(systemdata.Migrations(), businessdata.Migrations()...) { + if err := step.Migrate(db); err != nil { + t.Fatal(err) + } + } + if err := db.Exec("INSERT INTO sys_users(id,uuid,username,nick_name,authority_id,level,id_path,enable,business_status) VALUES(1,'root','root','root',888,0,'0',1,0),(2,'normal','normal','normal',901,1,'0',1,0),(3,'pending','pending','pending',903,3,'0,2',0,3),(4,'rejected','rejected','rejected',903,3,'0,2',0,2)").Error; err != nil { + t.Fatal(err) + } + provider := nativeUserHTTPData{businessHTTPData{db}, config.NewStore(&config.Config{Admin: &config.Admin{}})} + users := handler.NewUser(systemservice.NewUserService(system.NewUserUsecase(systemdata.NewUserRepo(provider)), nil), nil) + accounts := handler.NewBusiness(nil, businessservice.NewAccountService(businessbiz.NewAccountUsecase(businessdata.NewAccountRepo(provider))), nil) + engine := gin.New() + engine.Use(func(c *gin.Context) { + id := uint64(1) + if raw := c.GetHeader("X-Test-User"); raw != "" { + id, _ = strconv.ParseUint(raw, 10, 64) + } + aid := uint(903) + if id == 1 { + aid = 888 + } + c.Set("admin_claims", &system.AuthClaims{ID: uint(id), AuthorityID: aid}) + c.Request = c.Request.WithContext(system.NewActorContext(c.Request.Context(), system.Actor{UserID: uint(id), AuthorityID: aid})) + c.Next() + }) + router.RegisterUser(engine.Group(""), users) + engine.GET("/sw/business/:userId", accounts.Account) + server := httptest.NewServer(engine) + defer server.Close() + call := func(user int, method, path, body string, expected int) 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-User", strconv.Itoa(user)) + res, err := server.Client().Do(req) + if err != nil { + t.Fatal(err) + } + defer res.Body.Close() + var result map[string]any + if err := json.NewDecoder(res.Body).Decode(&result); err != nil { + t.Fatal(err) + } + if result["code"] != float64(expected) { + t.Fatalf("%s %s 返回错误: %+v", method, path, result) + } + return result["data"].(map[string]any) + } + check := func(id int, status string, enable int) { + t.Helper() + data := call(1, "GET", fmt.Sprintf("/sw/business/%d", id), "", 0) + var actual int + db.Table("sys_users").Select("enable").Where("id = ?", id).Scan(&actual) + if data["status"] != status || actual != enable { + t.Fatalf("账号%d状态不同步: %+v enable=%d", id, data, actual) + } + } + call(1, "PUT", "/user/setUserInfo", `{"ID":2,"nickName":"停用后","enable":2}`, 0) + check(2, "1", 2) + call(1, "PUT", "/user/setUserInfo", `{"ID":2,"nickName":"启用后","enable":1}`, 0) + check(2, "0", 1) + call(1, "PUT", "/user/setUserInfo", `{"ID":3,"nickName":"修改待审资料","enable":0}`, 0) + check(3, "3", 0) + call(1, "PUT", "/user/setUserInfo", `{"ID":4,"nickName":"修改驳回资料","enable":2}`, 0) + check(4, "2", 2) + call(1, "PUT", "/user/setUserInfo", `{"ID":3,"nickName":"审核后启用","enable":1}`, 0) + check(3, "0", 1) + // 模拟鉴权之后管理员又修改状态的竞争,个人资料不能用 enable 绕过审核。 + call(4, "PUT", "/user/setSelfInfo", `{"enable":1,"nickName":"试图自行启用"}`, 7) + check(4, "2", 2) + call(2, "PUT", "/user/setSelfInfo", `{"enable":2}`, 0) + check(2, "1", 2) + call(2, "PUT", "/user/setSelfInfo", `{"enable":1}`, 7) + check(2, "1", 2) + call(1, "PUT", "/user/setUserInfo", `{"ID":99999,"enable":1}`, 7) +} diff --git a/docs/sw6-to-kra/implementation.md b/docs/sw6-to-kra/implementation.md index 348bc9b..d2cb624 100644 --- a/docs/sw6-to-kra/implementation.md +++ b/docs/sw6-to-kra/implementation.md @@ -78,4 +78,6 @@ - 游戏分布只列当前渠道已通过的推广游戏,读取 `tab_sw_report_day` / `tab_sw_report_day_key`,人数按当前去重模式去重,金额固定模式 0。累计起点保留旧实现从当前人数口径的最早日汇总取得、缺省当天的规则,截止上海当天;不能与列表原始注册/充值口径混用。金额先保留两位再排序,同金额按游戏 ID。 - 新增渠道由 biz 决定一级、待审核 `-3`、默认注册类型和七天结算周期;data 在对应源平台库同事务写渠道、结算记录、商务渠道关系。密码复用旧 cmf 格式和 `SW_SDK_AUTH_CODE`。MySQL 创建使用固定连接持有账号命名锁直到事务提交,商务关系在行锁内追加;命名锁的真实并发验证留待最终隔离平台库联调。没有创建真实平台测试渠道,也没有伪造主库同步副本。 - 渠道批次通过全量 Go 检查、前端 ESLint/生产构建、独立主库和源平台 SQLite 的 TCP HTTP 验证。`channel-http-2026-11-03.json` 记录实际 sw7 会话请求,当前主库渠道数据未接入,lx/hw/yq 正常绑定选项分别为 17/20/20 条。`channel-page-2026-11-03.json` 核对一级商务新增入口、平台切换清空绑定、跨平台同号、零游戏数入口、缺失指标提示及新增提交参数。画像入口继续随画像用例迁移,当前不将整个渠道菜单标为完成。 +- 核对剩余用户接口的基线差异及 Kra 原生能力,记录于 `system-api-audit.md`;保留历史别名未注册、Excel 协议未兼容等差异,不把能力对应表当成接口兼容完成证明。商务页面仍不增加原页面没有的编辑/删除/导入/导出入口。 +- 修复原生用户启停只更新 enable、商务状态不同步的问题:由 biz 决定状态联动,在 data 行锁事务中一起写入;未启用时保留待审/驳回,个人资料请求不能自行解除管理员限制。当前 45 个真实用户只读检查未发现已有冲突,没有批量更新真实账号;正常启停、审核状态保留、失败回滚和越权通过隔离 HTTP/仓储/领域测试。全量 Go 测试通过,实际服务检查记录为 `user-status-http-2026-11-03.json`。 - 当前没有执行最终旧数据库复制与迁移验收。 diff --git a/docs/sw6-to-kra/system-api-audit.md b/docs/sw6-to-kra/system-api-audit.md new file mode 100644 index 0000000..581ed8b --- /dev/null +++ b/docs/sw6-to-kra/system-api-audit.md @@ -0,0 +1,42 @@ +# 系统接口承接核对 + +核对基线:旧后端 `d31082f4a7816d4c83544b8f54e6f42a3b2132b1` 至 `main`。原 SQL 的接口表按控制器收集接口,因此包含基线前已有的 RuoYi 用户管理接口;不能把所有记录都当成新商务新增功能,也不能只凭新路由存在就宣布功能完成。 + +## 商务新增与改动 + +`SysUserController` 的 Git 差异主要是商务列表语义、添加子商务,以及直属渠道、平台绑定和两种商务画像。用户列表查询新增商务树、姓名/等级过滤、直属渠道与正常绑定数量;添加子商务新增层级、归属与偏好初始化。当前列表、子商务、偏好和平台绑定已迁移;商务/平台商务画像仍未完成。 + +原用户控制器中的以下方法在基线前已经存在,旧 `BusMgmt/BusinessList.vue` 没有为它们增加实际操作入口。新商务页面不据此增加编辑、删除、导入、导出、角色分配等按钮。 + +| 旧目标别名 | 当前 Kra 能力 / 核对结果 | +| --- | --- | +| `GET /sw/business`(新增表单初始化) | 原生用户表单通过用户、角色、部门接口加载;商务新增表单由后端决定商务层级,不开放角色选择 | +| `DELETE /sw/business/:userIds` | 原生 `DELETE /user/deleteUser`;旧商务列表没有删除入口 | +| `GET /sw/business/optionselect` | 原生用户列表/部门用户查询;商务与报表实际使用各自的可见范围接口 | +| `PUT /sw/business/resetPwd` | 原生 `POST /user/resetPassword`;商务自助找回密码使用已迁移的独立流程 | +| `PUT /sw/business/changeStatus` | 原生 `PUT /user/setUserInfo`;本批补齐 enable 与 business_status 联动 | +| `GET /sw/business/authRole/:userId` | 原生用户角色资料及角色列表 | +| `PUT /sw/business/authRole` | 原生 `POST /user/setUserAuthorities` | +| `GET /sw/business/deptTree` | 原生 `POST /department/getDepartmentList` | +| `GET /sw/business/list/dept/:deptId` | 原生 `GET /department/getDepartmentUsers` | +| `POST /sw/business/export` | RuoYi Excel 导出协议尚未增加别名;旧导出调用共享查询构造器,会继承商务过滤,不能把原生通用导出直接算成相同协议已实现 | +| `POST /sw/business/importData` | RuoYi Excel 导入协议尚未实现;当前商务模板没有导入入口 | +| `GET /sw/business/unlock/:userId` | 原先删除 RuoYi Redis 密码错误键;新系统使用 Kra 鉴权/限制机制,不读取旧 Redis 键。旧协议别名尚未注册 | + +以上记录说明框架能力的对应关系,并非宣称旧别名已可调用。别名与原生接口的请求/响应并不相同;最终接口核对仍须保留这项差异,尤其不能把导入/导出列为已经通过兼容验收。当前不删除用户 SQL 中的这些历史接口元数据。 + +## 账号启停与商务状态 + +原 RuoYi 用 `status` 同时表示正常、停用、驳回和待审核。迁移后有 Kra 的 `enable` 和保留语义的 `business_status`。原生用户启停此前只改前者,可能导致商务列表仍显示正常,或启用后仍因旧状态无法登录。 + +本批处理: + +- 新建原生账号时,默认启用与禁用状态同步到商务状态。 +- 管理员明确启用时恢复正常;关闭启用时,普通状态变为停用,待审核/驳回继续保留,避免普通资料编辑丢失审核状态。 +- 读取当前业务状态与写入两个字段在同一行锁事务内完成,规则由 biz 回调决定,data 只做持久化。 +- 个人资料不传 enable 时仍按原有字段更新规则处理;传 enable 时不能越过管理员已经写入的停用、待审或驳回状态。 +- 角色范围检查继续执行,不增加商务列表的编辑入口。 + +隔离 TCP HTTP 测试覆盖原生启停后商务接口返回状态、待审/驳回资料编辑、自行启用被拒绝和不存在用户;仓储测试覆盖失败回滚与越权。现有鉴权测试继续检查停用/待审/驳回会拒绝密码登录和已有会话。 + +当前 sw7 的 45 个用户已做只读一致性检查,未发现 enable 与 business_status 冲突,因此没有批量改写真实账号。 diff --git a/docs/sw6-to-kra/user-status-http-2026-11-03.json b/docs/sw6-to-kra/user-status-http-2026-11-03.json new file mode 100644 index 0000000..088a1e7 --- /dev/null +++ b/docs/sw6-to-kra/user-status-http-2026-11-03.json @@ -0,0 +1,35 @@ +{ + "scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。", + "checks": [ + { + "method": "POST", + "path": "/user/getUserList", + "http": 200, + "code": 0, + "msg": "获取成功", + "passed": true + }, + { + "method": "GET", + "path": "/sw/business/list?pageNum=1&pageSize=500", + "http": 200, + "code": 0, + "msg": "成功", + "passed": true + }, + { + "method": "PUT", + "path": "/user/setUserInfo", + "http": 200, + "code": 7, + "msg": "ID值不能为空", + "passed": true + } + ], + "native_total": 45, + "business_total": 45, + "checked_users": 45, + "inconsistent_states": 0, + "response_mismatches": 0, + "passed": true +} diff --git a/internal/biz/system/user.go b/internal/biz/system/user.go index aa1aa84..caa9395 100644 --- a/internal/biz/system/user.go +++ b/internal/biz/system/user.go @@ -52,7 +52,9 @@ type UserRepo interface { ListUsers(context.Context, int, int, *UserListFilter) ([]*User, int64, error) CreateUserWithAuthorities(context.Context, *User, []uint) (*User, error) UpdateUser(context.Context, *User) error + UpdateUserWithStatus(context.Context, *User, func(int) (int, error)) error UpdateSelfUser(context.Context, *User) error + UpdateSelfUserWithStatus(context.Context, *User, func(int) (int, error)) error DeleteUser(context.Context, uint) error UpdatePassword(context.Context, uint, string, bool) error SetUserAuthorities(context.Context, uint, []uint) error @@ -108,6 +110,10 @@ func (uc *UserUsecase) ListUsers(ctx context.Context, page, pageSize int, filter return uc.repo.ListUsers(ctx, page, pageSize, filter) } func (uc *UserUsecase) CreateUser(ctx context.Context, user *User, authorityIDs []uint) (*User, error) { + if user.Enable == 0 { + user.Enable = 1 + } + user.BusinessStatus = businessStatusForEnable(0, user.Enable) hash, err := bcrypt.GenerateFromPassword([]byte(user.Password), bcrypt.DefaultCost) if err != nil { return nil, err @@ -125,10 +131,32 @@ func (uc *UserUsecase) UpdateUser(ctx context.Context, user *User, authorityIDs return err } } - return uc.repo.UpdateUser(ctx, user) + return uc.repo.UpdateUserWithStatus(ctx, user, func(current int) (int, error) { + return businessStatusForEnable(current, user.Enable), nil + }) +} + +// 启用代表恢复正常;未启用时保留待审核/驳回,避免编辑资料丢失业务审核状态。 +func businessStatusForEnable(current, enable int) int { + if enable == 1 { + return 0 + } + if current == 2 || current == 3 { + return current + } + return 1 } func (uc *UserUsecase) UpdateSelfUser(ctx context.Context, user *User) error { + if user.Enable != 0 { + return uc.repo.UpdateSelfUserWithStatus(ctx, user, func(current int) (int, error) { + // 个人资料请求不能越过管理员刚写入的停用或审核状态。 + if current != 0 { + return current, ErrUserDisabled + } + return businessStatusForEnable(current, user.Enable), nil + }) + } return uc.repo.UpdateSelfUser(ctx, user) } diff --git a/internal/biz/system/user_test.go b/internal/biz/system/user_test.go index 9d384a5..c3d83aa 100644 --- a/internal/biz/system/user_test.go +++ b/internal/biz/system/user_test.go @@ -59,3 +59,54 @@ func TestSetUserAuthoritiesRejectsEmptyListAsBadRequest(t *testing.T) { t.Fatalf("SetUserAuthorities() error = %v, want ErrAuthoritiesRequired", err) } } + +type statusUpdateRepo struct { + UserRepo + current, next int + created *User +} + +func (r *statusUpdateRepo) UpdateUserWithStatus(_ context.Context, _ *User, policy func(int) (int, error)) error { + var err error + r.next, err = policy(r.current) + return err +} +func (r *statusUpdateRepo) UpdateSelfUserWithStatus(ctx context.Context, user *User, policy func(int) (int, error)) error { + return r.UpdateUserWithStatus(ctx, user, policy) +} +func (r *statusUpdateRepo) CreateUserWithAuthorities(_ context.Context, user *User, _ []uint) (*User, error) { + r.created = user + return user, nil +} + +func TestNativeEnablePreservesBusinessReviewStates(t *testing.T) { + for _, tc := range []struct{ current, enable, want int }{{0, 2, 1}, {1, 1, 0}, {2, 0, 2}, {3, 2, 3}, {2, 1, 0}, {3, 1, 0}} { + repo := &statusUpdateRepo{current: tc.current} + if err := NewUserUsecase(repo).UpdateUser(context.Background(), &User{ID: 1, Enable: tc.enable}, nil); err != nil || repo.next != tc.want { + t.Fatalf("商务状态与启停不同步: %+v next=%d err=%v", tc, repo.next, err) + } + } +} + +func TestNativeCreationStatusMatchesDefaultAndDisabledEnable(t *testing.T) { + for _, tc := range []struct{ enable, wantEnable, wantStatus int }{{0, 1, 0}, {1, 1, 0}, {2, 2, 1}} { + repo := &statusUpdateRepo{} + _, err := NewUserUsecase(repo).CreateUser(context.Background(), &User{Username: "test", Password: "test-password", Enable: tc.enable}, nil) + if err != nil || repo.created.Enable != tc.wantEnable || repo.created.BusinessStatus != tc.wantStatus { + t.Fatalf("新账号状态错误: %+v %v", repo.created, err) + } + } +} + +func TestSelfProfileCannotApproveOrReenableBusinessState(t *testing.T) { + for _, status := range []int{1, 2, 3} { + repo := &statusUpdateRepo{current: status} + if err := NewUserUsecase(repo).UpdateSelfUser(context.Background(), &User{ID: 1, Enable: 1}); err != ErrUserDisabled || repo.next != status { + t.Fatal("个人资料修改越过了停用/审核状态") + } + } + repo := &statusUpdateRepo{current: 0} + if err := NewUserUsecase(repo).UpdateSelfUser(context.Background(), &User{ID: 1, Enable: 2}); err != nil || repo.next != 1 { + t.Fatal("个人资料停用字段没有同步商务状态") + } +} diff --git a/internal/data/system/user.go b/internal/data/system/user.go index 05d1a51..379c4d7 100644 --- a/internal/data/system/user.go +++ b/internal/data/system/user.go @@ -342,7 +342,7 @@ func (r *userRepo) CreateUserWithAuthorities(ctx context.Context, user *system.U user.NickName = "系统用户" } now := time.Now() - po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} + po := userPO{UUID: user.UUID, Username: user.Username, Password: user.Password, NickName: user.NickName, HeaderImg: user.HeaderImg, AuthorityID: user.AuthorityID, Phone: user.Phone, Email: user.Email, Enable: user.Enable, BusinessStatus: user.BusinessStatus, PasswordUpdatedAt: &now, MustChangePassword: user.MustChangePassword} var createdAuthorities []system.Authority if err := r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { // The compatible registration rejects an existing non-deleted username before creating the @@ -397,6 +397,34 @@ func (r *userRepo) UpdateUser(ctx context.Context, user *system.User) error { return r.updateUser(r.data.DB().WithContext(ctx), user) } +func (r *userRepo) UpdateUserWithStatus(ctx context.Context, user *system.User, updateStatus func(int) (int, error)) error { + if err := r.checkUserUpdateAuth(ctx, user); err != nil { + return err + } + return r.updateUserAndStatus(ctx, user.ID, userUpdateFields(user), updateStatus) +} + +func (r *userRepo) updateUserAndStatus(ctx context.Context, userID uint, updates map[string]any, updateStatus func(int) (int, error)) error { + return r.data.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var current userPO + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,business_status").First(¤t, userID).Error; err != nil { + if errors.Is(err, gorm.ErrRecordNotFound) { + return system.ErrUserNotFound + } + return err + } + // 使用锁内的最新审核状态运行领域策略,与 enable 在同一次更新中提交。 + if updateStatus != nil { + status, err := updateStatus(current.BusinessStatus) + if err != nil { + return err + } + updates["business_status"] = status + } + return tx.Model(&userPO{}).Where("id = ?", userID).Updates(updates).Error + }) +} + func (r *userRepo) checkUserUpdateAuth(ctx context.Context, user *system.User) error { access := &authorityAccessRepo{data: r.data} if err := access.checkUserIDAuth(ctx, user.ID, false); err != nil { @@ -409,6 +437,18 @@ func (r *userRepo) checkUserUpdateAuth(ctx context.Context, user *system.User) e } func (r *userRepo) UpdateSelfUser(ctx context.Context, user *system.User) error { + updates := selfUserUpdateFields(user) + if len(updates) == 0 { + return nil + } + return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error +} + +func (r *userRepo) UpdateSelfUserWithStatus(ctx context.Context, user *system.User, updateStatus func(int) (int, error)) error { + return r.updateUserAndStatus(ctx, user.ID, selfUserUpdateFields(user), updateStatus) +} + +func selfUserUpdateFields(user *system.User) map[string]any { updates := make(map[string]any) if user.NickName != "" { updates["nick_name"] = user.NickName @@ -425,10 +465,7 @@ func (r *userRepo) UpdateSelfUser(ctx context.Context, user *system.User) error if user.Enable != 0 { updates["enable"] = user.Enable } - if len(updates) == 0 { - return nil - } - return r.data.DB().WithContext(ctx).Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error + return updates } func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *system.User) error { @@ -473,11 +510,15 @@ func (r *userRepo) FillDepartmentNamePaths(ctx context.Context, user *system.Use } func (r *userRepo) updateUser(tx *gorm.DB, user *system.User) error { + return tx.Model(&userPO{}).Where("id = ?", user.ID).Updates(userUpdateFields(user)).Error +} + +func userUpdateFields(user *system.User) map[string]any { updates := map[string]any{"nick_name": user.NickName, "header_img": user.HeaderImg, "phone": user.Phone, "email": user.Email, "enable": user.Enable} if user.AuthorityID != 0 { updates["authority_id"] = user.AuthorityID } - return tx.Model(&userPO{}).Where("id = ?", user.ID).Updates(updates).Error + return updates } func (r *userRepo) DeleteUser(ctx context.Context, id uint) error { diff --git a/internal/data/system/user_business_status_test.go b/internal/data/system/user_business_status_test.go new file mode 100644 index 0000000..883afbf --- /dev/null +++ b/internal/data/system/user_business_status_test.go @@ -0,0 +1,43 @@ +package system + +import ( + "context" + "testing" + + "kra/internal/biz/system" +) + +func TestUserStatusPolicyReadsLockedStateAndWritesTogether(t *testing.T) { + data := newTransactionTestData(t) + db := data.gormDB.WithContext(context.Background()) + user := userPO{Username: "status-user", AuthorityID: 888, Enable: 0, BusinessStatus: 3, NickName: "old"} + if err := db.Create(&user).Error; err != nil { + t.Fatal(err) + } + repo := &userRepo{data: data} + seen := -1 + err := repo.UpdateUserWithStatus(context.Background(), &system.User{ID: user.ID, NickName: "new", Enable: 1}, func(current int) (int, error) { seen = current; return 0, nil }) + if err != nil { + t.Fatal(err) + } + var stored userPO + if err := db.First(&stored, user.ID).Error; err != nil { + t.Fatal(err) + } + if seen != 3 || stored.Enable != 1 || stored.BusinessStatus != 0 || stored.NickName != "new" { + t.Fatalf("状态策略和更新不同步: %+v seen=%d", stored, seen) + } + if err := db.Exec("CREATE TRIGGER fail_status_update BEFORE UPDATE OF business_status ON sys_users BEGIN SELECT RAISE(ABORT, 'test rejection'); END").Error; err != nil { + t.Fatal(err) + } + err = repo.UpdateUserWithStatus(context.Background(), &system.User{ID: user.ID, NickName: "failed", Enable: 2}, func(int) (int, error) { return 1, nil }) + if err == nil { + t.Fatal("失败的状态更新应回滚") + } + if err := db.First(&stored, user.ID).Error; err != nil { + t.Fatal(err) + } + if stored.Enable != 1 || stored.BusinessStatus != 0 || stored.NickName != "new" { + t.Fatal("留下部分状态或资料更新") + } +} diff --git a/internal/data/system/user_strict_test.go b/internal/data/system/user_strict_test.go index bf505d7..d9b3c3d 100644 --- a/internal/data/system/user_strict_test.go +++ b/internal/data/system/user_strict_test.go @@ -40,6 +40,9 @@ func TestStrictUserMutationsRejectOutsideTargetAndAllowSelfPassword(t *testing.T if err := repo.UpdateUser(ctx, &system.User{ID: users[2].ID, NickName: "changed"}); err == nil { t.Fatal("UpdateUser() accepted an out-of-scope target") } + if err := repo.UpdateUserWithStatus(ctx, &system.User{ID: users[2].ID, Enable: 1}, func(int) (int, error) { t.Fatal("越权时不应执行状态策略"); return 0, nil }); err == nil { + t.Fatal("启停同步接口放行了角色范围外用户") + } if err := repo.DeleteUser(ctx, users[2].ID); err == nil { t.Fatal("DeleteUser() accepted an out-of-scope target") } diff --git a/scripts/sw_user_status_http_smoke.py b/scripts/sw_user_status_http_smoke.py new file mode 100644 index 0000000..351a134 --- /dev/null +++ b/scripts/sw_user_status_http_smoke.py @@ -0,0 +1,48 @@ +"""只读核对原生账号与商务状态;写接口只发送缺失 ID 的无效请求。""" + +import argparse +import json +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() + checks = [] + + def request(method, path, body=None, expected=0): + req = urllib.request.Request(args.base_url + path, method=method, + headers={"x-token": token, "Content-Type": "application/json"}, + data=json.dumps(body).encode() if body is not None else None) + with urllib.request.urlopen(req, timeout=30) as response: + result = json.load(response) + checks.append({"method": method, "path": path, "http": response.status, + "code": result.get("code"), "msg": result.get("msg"), + "passed": response.status == 200 and result.get("code") == expected}) + return result.get("data", {}) + + native = request("POST", "/user/getUserList", {"page": 1, "pageSize": 500}) + business = request("GET", "/sw/business/list?pageNum=1&pageSize=500") + native_rows = native.get("list", []) + by_id = {row["userId"]: row for row in business.get("rows", [])} + conflicts = sum((row.get("enable") == 1) != (row.get("businessStatus") == 0) for row in native_rows) + mismatches = sum(str(row.get("businessStatus")) != by_id.get(row["ID"], {}).get("status") for row in native_rows) + request("PUT", "/user/setUserInfo", {"ID": 0, "enable": 1}, expected=7) + report = {"scope": "真实 sw7 登录会话,只读检查;未启停任何真实账号。正向启停和审核状态保留由隔离 TCP HTTP 测试覆盖。", + "checks": checks, "native_total": native.get("total"), "business_total": business.get("total"), + "checked_users": len(native_rows), "inconsistent_states": conflicts, "response_mismatches": mismatches, + "passed": all(item["passed"] for item in checks) and conflicts == 0 and mismatches == 0 + and len(native_rows) == native.get("total") == business.get("total")} + args.output.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + print(json.dumps(report, ensure_ascii=False)) + if not report["passed"]: + raise SystemExit(1) + + +if __name__ == "__main__": + main()