fix: 同步原生账号启停与商务审核状态
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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`。
|
||||
- 当前没有执行最终旧数据库复制与迁移验收。
|
||||
|
||||
@@ -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 冲突,因此没有批量改写真实账号。
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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("个人资料停用字段没有同步商务状态")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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("留下部分状态或资料更新")
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user