diff --git a/.gitignore b/.gitignore index 90cb86f9..75f9c656 100644 --- a/.gitignore +++ b/.gitignore @@ -188,6 +188,7 @@ crash.*.log # Containers and local runtime data /data/ +/.octobus-mbs-mac/ /playground/ /qemu-compose.yml docker-compose.override.yml diff --git a/internal/protocol/gateway.go b/internal/protocol/gateway.go index 3d724a82..8fc18e0f 100644 --- a/internal/protocol/gateway.go +++ b/internal/protocol/gateway.go @@ -1594,24 +1594,29 @@ func (g *Gateway) invokeOnDemand(ctx context.Context, item store.ExposedMethod, if !info.Mode().IsRegular() { return nil, status.Errorf(codes.Internal, "runtime entry %q is not a regular file", item.Service.NodeEntry) } - secretFile, closeSecret, err := secretReadFile(inst.SecretJSON) + secretArg, secretFile, closeSecret, err := runtimeSecretArg(inst.SecretJSON) if err != nil { - return nil, status.Errorf(codes.Internal, "prepare secret fd: %v", err) + return nil, status.Errorf(codes.Internal, "prepare runtime secret: %v", err) } defer closeSecret() - cmd := exec.CommandContext(ctx, entry, + args := []string{ "--runtime", "invoke", "--method", item.Method.FullName, "--config", filepath.Join(workdir, "config.json"), - "--secret-fd", "3", + } + args = append(args, secretArg...) + args = append(args, "--metadata", metadataPath, "--workdir", workdir, "--service", item.Service.ID, "--instance", item.Instance.ID, ) + cmd := exec.CommandContext(ctx, entry, args...) cmd.Dir = workdir - cmd.ExtraFiles = []*os.File{secretFile} + if secretFile != nil { + cmd.ExtraFiles = []*os.File{secretFile} + } cmd.Env = append(os.Environ(), "OCTOBUS_SERVICE_ID="+item.Service.ID, "OCTOBUS_INSTANCE_ID="+item.Instance.ID, @@ -1658,6 +1663,14 @@ func secretReadFile(secret []byte) (*os.File, func(), error) { return reader, closeFn, nil } +func runtimeSecretArg(secret []byte) ([]string, *os.File, func(), error) { + secretFile, closeFn, err := secretReadFile(secret) + if err != nil { + return nil, nil, nil, err + } + return []string{"--secret-fd", "3"}, secretFile, closeFn, nil +} + func (g *Gateway) validateOnDemandResponse(item store.ExposedMethod, raw []byte) error { set, err := descriptors.Load(item.Service.DescriptorPath) if err != nil { diff --git a/internal/supervisor/supervisor.go b/internal/supervisor/supervisor.go index 9128a23b..093fb9fe 100644 --- a/internal/supervisor/supervisor.go +++ b/internal/supervisor/supervisor.go @@ -36,10 +36,19 @@ type Supervisor struct { } type processState struct { - cmd *exec.Cmd - done chan struct{} - attempt int - generation int64 + cmd *exec.Cmd + done chan struct{} + attempt int + generation int64 + cleanup func() + cleanupOnce sync.Once +} + +func (p *processState) cleanupRuntimeSecret() { + if p == nil || p.cleanup == nil { + return + } + p.cleanupOnce.Do(p.cleanup) } type CreateInstanceRequest struct { @@ -230,15 +239,20 @@ func (s *Supervisor) startWithAttempt(ctx context.Context, instanceID string, re startErr = fmt.Errorf("runtime entry %q is not a regular file", svc.NodeEntry) return startErr } - secretFile, closeSecret, err := secretReadFile(inst.SecretJSON) + secretArg, secretFile, closeSecret, err := runtimeSecretArg(inst.SecretJSON) if err != nil { startErr = err return err } - defer closeSecret() - cmd := exec.Command(entry, "--runtime", "serve", "--host", "127.0.0.1", "--port", fmt.Sprintf("%d", port), "--config", filepath.Join(workdir, "config.json"), "--secret-fd", "3", "--workdir", workdir, "--service", svc.ID, "--instance", instanceID) + cleanupSecret := closeSecret + args := []string{"--runtime", "serve", "--host", "127.0.0.1", "--port", fmt.Sprintf("%d", port), "--config", filepath.Join(workdir, "config.json")} + args = append(args, secretArg...) + args = append(args, "--workdir", workdir, "--service", svc.ID, "--instance", instanceID) + cmd := exec.Command(entry, args...) cmd.Dir = workdir - cmd.ExtraFiles = []*os.File{secretFile} + if secretFile != nil { + cmd.ExtraFiles = []*os.File{secretFile} + } cmd.Env = append(os.Environ(), "OCTOBUS_SERVICE_ID="+svc.ID, "OCTOBUS_INSTANCE_ID="+instanceID, @@ -248,12 +262,14 @@ func (s *Supervisor) startWithAttempt(ctx context.Context, instanceID string, re ) stdout, err := os.OpenFile(filepath.Join(workdir, "stdout.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { + cleanupSecret() startErr = err return err } stderr, err := os.OpenFile(filepath.Join(workdir, "stderr.log"), os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o600) if err != nil { _ = stdout.Close() + cleanupSecret() startErr = err return err } @@ -265,12 +281,14 @@ func (s *Supervisor) startWithAttempt(ctx context.Context, instanceID string, re if err := s.Store.UpsertInstance(ctx, inst); err != nil { _ = stdout.Close() _ = stderr.Close() + cleanupSecret() startErr = err return err } if err := cmd.Start(); err != nil { _ = stdout.Close() _ = stderr.Close() + cleanupSecret() inst.Status = domain.StatusFailed _ = s.Store.UpsertInstance(ctx, inst) startErr = err @@ -281,12 +299,13 @@ func (s *Supervisor) startWithAttempt(ctx context.Context, instanceID string, re inst.Status = domain.StatusRunning if err := s.Store.UpsertInstance(ctx, inst); err != nil { _ = cmd.Process.Kill() + cleanupSecret() startErr = err return err } logger.Info("instance_started", "instance_id", instanceID, "pid", pid, "listen_addr", addr) s.mu.Lock() - state := &processState{cmd: cmd, done: make(chan struct{}), attempt: restartAttempt, generation: generation} + state := &processState{cmd: cmd, done: make(chan struct{}), attempt: restartAttempt, generation: generation, cleanup: cleanupSecret} s.procs[instanceID] = state s.mu.Unlock() if err := waitHealth(ctx, addr, 5*time.Second); err != nil { @@ -313,6 +332,7 @@ func (s *Supervisor) cleanupFailedStart(instanceID string, state *processState, _ = state.cmd.Process.Kill() _ = state.cmd.Wait() } + state.cleanupRuntimeSecret() close(state.done) _ = stdout.Close() _ = stderr.Close() @@ -414,6 +434,7 @@ func (s *Supervisor) stopProcess(ctx context.Context, inst domain.Instance, enab case <-time.After(2 * time.Second): } } + state.cleanupRuntimeSecret() } if err := s.Store.UpsertInstance(ctx, inst); err != nil { logger.Error("instance_stop_failed", "instance_id", inst.ID, "error", err) @@ -523,10 +544,19 @@ func secretReadFile(secret []byte) (*os.File, func(), error) { return reader, closeFn, nil } +func runtimeSecretArg(secret []byte) ([]string, *os.File, func(), error) { + secretFile, closeFn, err := secretReadFile(secret) + if err != nil { + return nil, nil, nil, err + } + return []string{"--secret-fd", "3"}, secretFile, closeFn, nil +} + func (s *Supervisor) wait(instanceID string, state *processState, stdout, stderr *os.File) { err := state.cmd.Wait() _ = stdout.Close() _ = stderr.Close() + state.cleanupRuntimeSecret() close(state.done) s.mu.Lock() current := s.procs[instanceID] diff --git a/internal/supervisor/supervisor_test.go b/internal/supervisor/supervisor_test.go index 4cb8f37c..0f466fec 100644 --- a/internal/supervisor/supervisor_test.go +++ b/internal/supervisor/supervisor_test.go @@ -1035,6 +1035,45 @@ func TestWaitMarksEnabledInstanceDegradedAfterProcessError(t *testing.T) { } } +func TestStopProcessCleansRuntimeSecretAfterWaitTimeout(t *testing.T) { + ctx := context.Background() + dataDir := filepath.Join(t.TempDir(), "data") + st, err := store.Open(filepath.Join(dataDir, "octobus.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + if err := st.UpsertService(ctx, domain.Service{ID: "echo", Name: "Echo", PackageSource: "fixture", PackageArtifactPath: "pkg", PackageSHA256: "pkgsha", DescriptorPath: "desc", DescriptorSHA256: "descsha", DescriptorVersion: "descsha", NodeEntry: "entry"}); err != nil { + t.Fatal(err) + } + inst := domain.Instance{ID: "echo-test", ServiceID: "echo", Name: "Echo Test", Enabled: true, Status: domain.StatusRunning, NodeEntry: "entry", ConfigJSON: []byte(`{}`), SecretJSON: []byte(`{}`)} + if err := st.UpsertInstance(ctx, inst); err != nil { + t.Fatal(err) + } + sup := New(dataDir, st) + cmd := exec.Command("sh", "-c", "exit 0") + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + cleanupCalls := 0 + state := &processState{cmd: cmd, done: make(chan struct{}), cleanup: func() { cleanupCalls++ }} + sup.mu.Lock() + sup.procs["echo-test"] = state + sup.mu.Unlock() + + if err := sup.stopProcess(ctx, inst, false); err != nil { + t.Fatal(err) + } + if cleanupCalls != 1 { + t.Fatalf("cleanup calls after stop timeout=%d want 1", cleanupCalls) + } + state.cleanupRuntimeSecret() + if cleanupCalls != 1 { + t.Fatalf("cleanup should be idempotent, calls=%d", cleanupCalls) + } + _ = cmd.Wait() +} + func TestWaitReturnsWhenInstanceDisabledOrStateReplaced(t *testing.T) { ctx := context.Background() dataDir := filepath.Join(t.TempDir(), "data") diff --git a/services/package.json b/services/package.json index 0c8a0083..50d73b00 100644 --- a/services/package.json +++ b/services/package.json @@ -59,7 +59,8 @@ "tencent-bh": "bin/tencent-bh.js", "alibaba-sas": "bin/alibaba-sas.js", "misp": "bin/misp.js", - "opencti": "bin/opencti.js" + "opencti": "bin/opencti.js", + "zhizhangyi-mbs": "zhizhangyi__mbs/bin/zhizhangyi-mbs.js" }, "files": [ "bin/alibaba-cloud-simple-application-server-firewall.js", @@ -173,7 +174,14 @@ "filigran__opencti", "wangsu__label-ip", "scripts", - "bin/octobus-tentacles.js" + "bin/octobus-tentacles.js", + "zhizhangyi__mbs/bin/zhizhangyi-mbs.js", + "zhizhangyi__mbs/config.schema.json", + "zhizhangyi__mbs/package.json", + "zhizhangyi__mbs/proto", + "zhizhangyi__mbs/secret.schema.json", + "zhizhangyi__mbs/service.json", + "zhizhangyi__mbs/src" ], "scripts": { "validate": "node scripts/validate-service-package.mjs", diff --git a/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js new file mode 100755 index 00000000..508272f0 --- /dev/null +++ b/services/zhizhangyi__mbs/bin/zhizhangyi-mbs.js @@ -0,0 +1,7 @@ +#!/usr/bin/env node + +import { runServiceMain } from "@chaitin-ai/octobus-sdk"; + +import { service } from "../src/service.js"; + +runServiceMain(service); diff --git a/services/zhizhangyi__mbs/config.schema.json b/services/zhizhangyi__mbs/config.schema.json new file mode 100644 index 00000000..ad7646c5 --- /dev/null +++ b/services/zhizhangyi__mbs/config.schema.json @@ -0,0 +1,26 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "endpoint": { + "type": "string", + "description": "MBS REST API base URL. Example: https://mbs.example.com:9074" + }, + "baseUrl": { + "type": "string", + "description": "Legacy alias for endpoint." + }, + "skipTlsVerify": { + "type": "boolean", + "default": false, + "description": "Skip TLS certificate verification. Enable only when the target MBS endpoint uses a trusted self-signed certificate." + }, + "timeoutMs": { + "type": "integer", + "minimum": 1, + "default": 30000, + "description": "HTTP timeout in milliseconds." + } + } +} diff --git a/services/zhizhangyi__mbs/package.json b/services/zhizhangyi__mbs/package.json new file mode 100644 index 00000000..b9df352f --- /dev/null +++ b/services/zhizhangyi__mbs/package.json @@ -0,0 +1,20 @@ +{ + "name": "zhizhangyi-mbs", + "version": "0.0.0", + "private": true, + "type": "module", + "files": [ + "bin/zhizhangyi-mbs.js", + "config.schema.json", + "secret.schema.json", + "service.json", + "proto", + "src" + ], + "bin": { + "zhizhangyi-mbs": "bin/zhizhangyi-mbs.js" + }, + "dependencies": { + "@chaitin-ai/octobus-sdk": "^0.5.0" + } +} diff --git a/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto b/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto new file mode 100644 index 00000000..454de278 --- /dev/null +++ b/services/zhizhangyi__mbs/proto/zhizhangyi_mbs.proto @@ -0,0 +1,353 @@ +syntax = "proto3"; + +package zhizhangyi.mbs; + +import "google/protobuf/struct.proto"; + +// ============================================================================= +// MBS (Mobile Security Management Platform) User Management API +// Vendor: 北京指掌易科技有限公司 +// Base URL: https://{host}:9074/uusafe/mos/thirdaccess/rest/opt/{version}/{operator} +// Auth: MD5(appkey + param1 + param2 + ... + secretkey), 拼接不含 "+" +// ============================================================================= + +service UserManagement { + // 6.2.1 用户列表(分页/筛选) + rpc GetUsers(GetUsersRequest) returns (GetUsersResponse); + + // 6.2.2 新增用户 + rpc AddUser(AddUserRequest) returns (AddUserResponse); + + // 6.2.3 编辑用户 + rpc UpdUser(UpdUserRequest) returns (UpdUserResponse); + + // 6.2.4 用户详情 + rpc DetailUser(DetailUserRequest) returns (DetailUserResponse); + + // 6.2.5 删除用户 + rpc DelUsers(DelUsersRequest) returns (DelUsersResponse); + + // 6.2.6 启停用户 + rpc StateUsers(StateUsersRequest) returns (StateUsersResponse); + + // 6.2.7 账号校验 + rpc CheckLoginName(CheckLoginNameRequest) returns (CheckLoginNameResponse); + + // 6.2.8 根据手机号获取用户 + rpc GetUserByPhone(GetUserByPhoneRequest) returns (GetUserByPhoneResponse); + + // 6.2.9 / 6.2.10 修改密码 + rpc UpdUserPwd(UpdUserPwdRequest) returns (UpdUserPwdResponse); + + // 6.2.11 强制下线 + rpc ForceOffline(ForceOfflineRequest) returns (ForceOfflineResponse); + + // 6.2.12 导入用户 + rpc ImportUser(ImportUserRequest) returns (ImportUserResponse); +} + +// ============================================================================= +// Common / Shared Messages +// ============================================================================= + +// 扩展字段键值对 +message ExAttrVo { + string attr_key = 1; + string attr_value = 2; +} + +// 查询条件 map +message GetUsersCondition { + string key_word = 1; // 关键字(用户名/姓名/手机号/工号/邮箱) + string state = 2; // 激活状态 -1未激活 0停用 1激活(逗号分隔多选) + string is_mdm = 3; // 是否开启设备管控 0未开启 1开启(逗号分隔多选) + string dept_id = 4; // 部门ID +} + +// 删除/启停条件 map +message BatchCondition { + string key_word = 1; + string status = 2; + string is_mdm = 3; + string dept_id = 4; +} + +// 用户基本信息(列表项) +message UserInfo { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string phone_number = 4; + string email = 5; + string employee_number = 6; + string dept_id = 7; + string dept_name = 8; + int32 device_count = 9; + int32 is_mdm = 10; + int32 state = 11; + int32 is_admin = 12; + int32 user_source = 13; + int32 status = 14; + int32 weight = 15; + repeated ExAttrVo attrs = 16; + string dept_full_id = 17; + string dept_full_path = 18; + string job = 19; + string mobile = 20; + string address = 21; + string organization = 22; +} + +// 用户详情 +message UserDetail { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string dept_id = 4; + string dept_name = 5; + string phone_number = 6; + string job = 7; + string employee_number = 8; + string address = 9; + string mobile = 10; + string email = 11; + string organization = 12; + int32 is_mdm = 13; + int32 state = 14; + int32 weight = 15; + string icon_file_id = 16; + repeated ExAttrVo attrs = 17; +} + +// ============================================================================= +// 6.2.1 GetUsers - 用户列表 +// ============================================================================= +message GetUsersRequest { + int32 index = 1; // 页号(0起始) + int32 size = 2; // 每页数量(默认10,最大5000) + int32 order_code = 3; // 排序字段 0用户名 1姓名 2工号 3用户状态 + int32 order_type = 4; // 排序类型 0 asc 1 desc + GetUsersCondition condition = 5; // 查询条件 + string org_code = 6; // 机构编码 + string appkey = 7; // 接入账号 + string sign = 8; // MD5签名(自动计算) +} + +message GetUsersResponse { + int32 code = 1; + string msg = 2; + GetUsersData data = 3; + string time_stamp = 4; +} + +message GetUsersData { + int32 total = 1; + repeated UserInfo user_infos = 2; +} + +// ============================================================================= +// 6.2.2 AddUser - 新增用户 +// ============================================================================= +message AddUserRequest { + string user_name = 1; // 用户姓名(必填) + string login_name = 2; // 登录名(必填) + string dept_id = 3; // 部门ID(必填) + string password = 4; // MBS要求的3DES加密后密码串(必填,由调用方提供) + string phone_number = 5; + string job = 6; + string employee_number = 7; + string address = 8; + string mobile = 9; + string email = 10; + string organization = 11; + int32 user_source = 12; // 0本地 2第三方(必填) + int32 is_mdm = 13; // 是否开启设备管理 0否 1是 + int32 state = 14; // 1启用 0停用 -1未激活 + int32 weight = 15; + repeated ExAttrVo attrs = 16; + string org_code = 17; + string appkey = 18; + string sign = 19; +} + +message AddUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; // 通常为空对象 +} + +// ============================================================================= +// 6.2.3 UpdUser - 编辑用户 +// ============================================================================= +message UpdUserRequest { + string user_id = 1; // 用户ID(必填) + string user_name = 2; // 用户姓名(必填) + string login_name = 3; // 登录名(不可修改账号) + string dept_id = 4; // 部门ID(必填) + string phone_number = 5; + string job = 6; + string employee_number = 7; + string address = 8; + string mobile = 9; + string email = 10; + string organization = 11; + int32 is_mdm = 12; + int32 weight = 13; + repeated ExAttrVo attrs = 14; + string org_code = 15; + string appkey = 16; + string sign = 17; +} + +message UpdUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.4 DetailUser - 用户详情 +// ============================================================================= +message DetailUserRequest { + string user_id = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message DetailUserResponse { + int32 code = 1; + string msg = 2; + UserDetail data = 3; +} + +// ============================================================================= +// 6.2.5 DelUsers - 删除用户 +// ============================================================================= +message DelUsersRequest { + repeated string user_ids = 1; // 用户ID列表 + int32 type = 2; // 0指定ID删除 1指定条件删除 + BatchCondition condition = 3; // type=1时的查询条件 + string org_code = 4; + string appkey = 5; + string sign = 6; +} + +message DelUsersResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.6 StateUsers - 启停用户 +// ============================================================================= +message StateUsersRequest { + repeated string user_ids = 1; + int32 type = 2; // 0指定ID 1指定条件 + string state = 3; // 0停用 1启用 + BatchCondition condition = 4; + string org_code = 5; + string appkey = 6; + string sign = 7; +} + +message StateUsersResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.7 CheckLoginName - 账号校验 +// ============================================================================= +message CheckLoginNameRequest { + string login_name = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message CheckLoginNameResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.8 GetUserByPhone - 根据手机号获取用户 +// ============================================================================= +message GetUserByPhoneRequest { + string phone = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message GetUserByPhoneResponse { + int32 code = 1; + string msg = 2; + repeated PhoneUserInfo data = 3; +} + +message PhoneUserInfo { + string user_id = 1; + string user_name = 2; + string login_name = 3; + string phone_number = 4; + string email = 5; +} + +// ============================================================================= +// 6.2.9 / 6.2.10 UpdUserPwd - 修改密码 +// ============================================================================= +message UpdUserPwdRequest { + string user_id = 1; // v1版本必填 + string password = 2; // MBS要求的3DES加密后新密码串(v1,由调用方提供) + string old_pwd = 3; // MBS要求的3DES加密后原密码串(v2,由调用方提供) + string new_pwd = 4; // MBS要求的3DES加密后新密码串(v2,由调用方提供) + string login_name = 5; // v2版本必填 + string version = 6; // "v1" 管理员修改 / "v2" 用户自服务 + string org_code = 7; + string appkey = 8; + string sign = 9; +} + +message UpdUserPwdResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.11 ForceOffline - 强制下线 +// ============================================================================= +message ForceOfflineRequest { + string user_id = 1; + string org_code = 2; + string appkey = 3; + string sign = 4; +} + +message ForceOfflineResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} + +// ============================================================================= +// 6.2.12 ImportUser - 导入用户 +// ============================================================================= +message ImportUserRequest { + int32 lang = 1; // 0中文 1英文 2简体中文 + string file_id = 2; // 文件ID + string org_code = 3; + string appkey = 4; + string sign = 5; +} + +message ImportUserResponse { + int32 code = 1; + string msg = 2; + google.protobuf.Value data = 3; +} \ No newline at end of file diff --git a/services/zhizhangyi__mbs/secret.schema.json b/services/zhizhangyi__mbs/secret.schema.json new file mode 100644 index 00000000..8e621f3c --- /dev/null +++ b/services/zhizhangyi__mbs/secret.schema.json @@ -0,0 +1,19 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "additionalProperties": true, + "properties": { + "appkey": { + "type": "string", + "description": "MBS third-party access appkey." + }, + "secretkey": { + "type": "string", + "description": "MBS third-party access secretkey paired with appkey." + }, + "orgCode": { + "type": "string", + "description": "Organization code (orgCode) for MBS tenant." + } + } +} diff --git a/services/zhizhangyi__mbs/service.json b/services/zhizhangyi__mbs/service.json new file mode 100644 index 00000000..9fb14081 --- /dev/null +++ b/services/zhizhangyi__mbs/service.json @@ -0,0 +1,69 @@ +{ + "schema": "chaitin.octobus.service.v1", + "name": "zhizhangyi-mbs", + "displayName": "MBS Mobile Security Management", + "description": "OctoBus package for 指掌易 MBS (Mobile Security) User Management APIs. Provides CRUD for users, password management, account validation, force offline, and bulk import.", + "runtime": { + "mode": "long-running" + }, + "proto": { + "roots": [ + "proto" + ], + "files": [ + "proto/zhizhangyi_mbs.proto" + ] + }, + "configSchema": "config.schema.json", + "secretSchema": "secret.schema.json", + "sdk": { + "cli": { + "commands": { + "zhizhangyi.mbs.UserManagement/GetUsers": { + "name": "get-users", + "description": "List MBS users with pagination, filtering, and sorting." + }, + "zhizhangyi.mbs.UserManagement/AddUser": { + "name": "add-user", + "description": "Create a new MBS user." + }, + "zhizhangyi.mbs.UserManagement/UpdUser": { + "name": "update-user", + "description": "Update an existing MBS user." + }, + "zhizhangyi.mbs.UserManagement/DetailUser": { + "name": "detail-user", + "description": "Get MBS user detail by user ID." + }, + "zhizhangyi.mbs.UserManagement/DelUsers": { + "name": "delete-users", + "description": "Delete MBS users by ID or condition." + }, + "zhizhangyi.mbs.UserManagement/StateUsers": { + "name": "state-users", + "description": "Enable or disable MBS users." + }, + "zhizhangyi.mbs.UserManagement/CheckLoginName": { + "name": "check-login-name", + "description": "Check if a login name is available." + }, + "zhizhangyi.mbs.UserManagement/GetUserByPhone": { + "name": "get-user-by-phone", + "description": "Find MBS users by phone number." + }, + "zhizhangyi.mbs.UserManagement/UpdUserPwd": { + "name": "update-user-password", + "description": "Change user password (admin or self-service)." + }, + "zhizhangyi.mbs.UserManagement/ForceOffline": { + "name": "force-offline", + "description": "Force a user to be logged out." + }, + "zhizhangyi.mbs.UserManagement/ImportUser": { + "name": "import-user", + "description": "Import users from an uploaded file." + } + } + } + } +} diff --git a/services/zhizhangyi__mbs/src/service.js b/services/zhizhangyi__mbs/src/service.js new file mode 100644 index 00000000..d69d1c52 --- /dev/null +++ b/services/zhizhangyi__mbs/src/service.js @@ -0,0 +1,7 @@ +import { defineService } from "@chaitin-ai/octobus-sdk"; + +import { handlers } from "./zhizhangyi-mbs.js"; + +export { handlers } from "./zhizhangyi-mbs.js"; + +export const service = defineService({ handlers }); diff --git a/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js new file mode 100644 index 00000000..d8998d90 --- /dev/null +++ b/services/zhizhangyi__mbs/src/zhizhangyi-mbs.js @@ -0,0 +1,278 @@ +// MBS User Management API Proxy - Part 1: helpers + first 6 handlers +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; +import crypto from 'node:crypto'; + +const DEF_TO = 30000; +const BASE = '/uusafe/mos/thirdaccess/rest/opt'; + +const has = (o, k) => Object.prototype.hasOwnProperty.call(o ?? {}, k); +const first = (...vs) => vs.find((v) => v !== undefined && v !== null); +const merge = (ctx = {}) => ({ ...(ctx?.config ?? {}), ...(ctx?.secret ?? {}), ...(ctx?.bindings ?? {}) }); +const normUrl = (u) => /^https?:\/\//i.test(String(u || '').trim()) ? String(u).trim().replace(/\/$/, '') : null; +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const signCalc = (sk, ...ps) => md5(ps.map((p) => (p === undefined || p === null ? '' : String(p))).join('') + String(sk || '')); +const arr = (v) => Array.isArray(v) ? v : []; +const str = (v) => (v === undefined || v === null || v === '') ? undefined : String(v); +const num = (v) => (v === undefined || v === null) ? undefined : Number(v); +const gc = (c) => ({ INVALID_ARGUMENT: grpcStatus.INVALID_ARGUMENT, FAILED_PRECONDITION: grpcStatus.FAILED_PRECONDITION, PERMISSION_DENIED: grpcStatus.PERMISSION_DENIED, UNAVAILABLE: grpcStatus.UNAVAILABLE, DEADLINE_EXCEEDED: grpcStatus.DEADLINE_EXCEEDED })[c] ?? grpcStatus.UNKNOWN; +const er = (c, m) => { const e = new GrpcError(gc(c), c + ': ' + m); e.legacyCode = c; return e; }; +const doFetch = async (url, init, to, st) => { const tls = st ? { insecureSkipVerify: true, tlsInsecureSkipVerify: true } : {}; try { return await fetch(url, { ...init, timeoutMs: to, ...tls }); } catch (e) { throw er('UNAVAILABLE', e?.cause?.message || e?.message || 'fetch failed'); } }; +const rdJson = async (res) => { const t = await res.text(); if (!res.ok) throw er(res.status === 401 || res.status === 403 ? 'PERMISSION_DENIED' : res.status >= 400 && res.status < 500 ? 'FAILED_PRECONDITION' : 'UNAVAILABLE', 'http ' + res.status + ': ' + t); if (!t.trim()) return {}; try { return JSON.parse(t); } catch { throw er('UNKNOWN', 'not JSON'); } }; +const check = (j) => { if (j.code !== undefined && j.code !== 0) throw er('FAILED_PRECONDITION', 'MBS code=' + j.code + ': ' + (j.msg || '')); }; +const buildCond = (cond) => { const c = {}; const kw = first(cond?.key_word, cond?.keyWord); if (kw !== undefined) c.keyWord = kw; if (cond?.status !== undefined && cond?.status !== null) c.status = cond.status; const im = first(cond?.is_mdm, cond?.isMdm); if (im !== undefined) c.isMdm = im; const did = first(cond?.dept_id, cond?.deptId); if (did !== undefined) c.deptId = did; return c; }; +const toValue = (value) => { + if (value === undefined || value === null) return { nullValue: 'NULL_VALUE' }; + if (typeof value === 'string') return { stringValue: value }; + if (typeof value === 'number') return Number.isFinite(value) ? { numberValue: value } : { stringValue: String(value) }; + if (typeof value === 'boolean') return { boolValue: value }; + if (Array.isArray(value)) return { listValue: { values: value.map((item) => toValue(item)) } }; + if (typeof value === 'object') { + const fields = {}; + for (const [k, v] of Object.entries(value)) fields[k] = toValue(v); + return { structValue: { fields } }; + } + return { stringValue: String(value) }; +}; + +const mapUser = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '', employee_number: it?.employeeNumber ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', device_count: it?.deviceCount ?? 0, is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, is_admin: it?.isAdmin ?? 0, user_source: it?.userSource ?? 0, status: it?.status ?? 1, weight: it?.weight ?? 0, attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })), dept_full_id: it?.deptFullId ?? '', dept_full_path: it?.deptFullPath ?? '', job: it?.job ?? '', mobile: it?.mobile ?? '', address: it?.address ?? '', organization: it?.organization ?? '' }); +const mapDetail = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', dept_id: it?.deptId ?? '', dept_name: it?.deptName ?? '', phone_number: it?.phoneNumber ?? '', job: it?.job ?? '', employee_number: it?.employeeNumber ?? '', address: it?.address ?? '', mobile: it?.mobile ?? '', email: it?.email ?? '', organization: it?.organization ?? '', is_mdm: it?.isMdm ?? 0, state: it?.state ?? 0, weight: it?.weight ?? 0, icon_file_id: it?.iconFileId ?? '', attrs: arr(it?.attrs).map((a) => ({ attr_key: a?.attrKey ?? '', attr_value: a?.attrValue ?? '' })) }); +const mapPhone = (it) => ({ user_id: it?.userId ?? '', user_name: it?.userName ?? '', login_name: it?.loginName ?? '', phone_number: it?.phoneNumber ?? '', email: it?.email ?? '' }); + +const M = { + GetUsers: 'zhizhangyi.mbs.UserManagement/GetUsers', + AddUser: 'zhizhangyi.mbs.UserManagement/AddUser', + UpdUser: 'zhizhangyi.mbs.UserManagement/UpdUser', + DetailUser: 'zhizhangyi.mbs.UserManagement/DetailUser', + DelUsers: 'zhizhangyi.mbs.UserManagement/DelUsers', + StateUsers: 'zhizhangyi.mbs.UserManagement/StateUsers', + CheckLoginName: 'zhizhangyi.mbs.UserManagement/CheckLoginName', + GetUserByPhone: 'zhizhangyi.mbs.UserManagement/GetUserByPhone', + UpdUserPwd: 'zhizhangyi.mbs.UserManagement/UpdUserPwd', + ForceOffline: 'zhizhangyi.mbs.UserManagement/ForceOffline', + ImportUser: 'zhizhangyi.mbs.UserManagement/ImportUser', +}; + +export function rpcdef(ctx) { + const b = merge(ctx); + const baseUrl = normUrl(b.endpoint || b.baseUrl || ''); + const to = ctx?.limits?.timeoutMs || b.timeoutMs || DEF_TO; + const skipTls = Boolean(b.skipTlsVerify); + const ak = b.appkey || ''; + const sk = b.secretkey || ''; + const ocDef = b.orgCode || ''; + + const post = async (path, body) => { + if (!baseUrl) throw er('INVALID_ARGUMENT', 'endpoint/baseUrl required'); + const r = await doFetch(baseUrl + path, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body) }, to, skipTls); + const j = await rdJson(r); check(j); return j; + }; + + // 6.2.1 GetUsers + const goGetUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; + const apk = first(req?.appkey) || ak; + const idx = first(req?.index) ?? 0; const sz = first(req?.size) ?? 10; + const odc = first(req?.order_code, req?.orderCode) ?? 0; const odt = first(req?.order_type, req?.orderType) ?? 1; + const did = first(req?.condition?.dept_id, req?.condition?.deptId) || '1'; + const kw = first(req?.condition?.key_word, req?.condition?.keyWord) || ''; + const st = first(req?.condition?.state) ?? ''; const md = first(req?.condition?.is_mdm, req?.condition?.isMdm) ?? ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, idx, sz, odc, odt, kw, st, md, did); + const cond = { deptId: did, keyWord: kw }; if (st !== '') cond.state = st; if (md !== '') cond.isMdm = md; + const j = await post(BASE + '/v1/getUsers', { index: Number(idx), size: Number(sz), orderCode: Number(odc), orderType: Number(odt), condition: cond, orgCode: oc, appkey: apk, sign: sg }); + const d = j?.data || {}; + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: { total: d.total ?? 0, user_infos: arr(d.userInfos).map(mapUser) }, time_stamp: j?.timeStamp ?? '' }; + }; + + // 6.2.2 AddUser + const goAddUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const un = first(req?.user_name, req?.userName) || ''; const ln = first(req?.login_name, req?.loginName) || ''; + const did = first(req?.dept_id, req?.deptId) || ''; const encryptedPw = first(req?.password) || ''; + if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); if (!ln) throw er('INVALID_ARGUMENT', 'login_name required'); if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); if (!encryptedPw) throw er('INVALID_ARGUMENT', 'password required as 3DES-encrypted value'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, un, ln, did, encryptedPw); + const body = { userName: un, loginName: ln, deptId: did, password: encryptedPw, userSource: Number(first(req?.user_source, req?.userSource) ?? 0), orgCode: oc, appkey: apk, sign: sg }; + const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; + for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } + for (const k of ['is_mdm', 'state', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } + if (arr(req?.attrs).length > 0) body.attrs = req.attrs.map((a) => ({ attrKey: a?.attr_key ?? '', attrValue: a?.attr_value ?? '' })); + const j = await post(BASE + '/v1/addUser', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.3 UpdUser + const goUpdUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; const un = first(req?.user_name, req?.userName) || ''; + if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); if (!un) throw er('INVALID_ARGUMENT', 'user_name required'); + const ln = first(req?.login_name, req?.loginName) || ''; const did = first(req?.dept_id, req?.deptId) || ''; + if (!did) throw er('INVALID_ARGUMENT', 'dept_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, un, ln, did); + const body = { userId: uid, userName: un, loginName: ln, deptId: did, orgCode: oc, appkey: apk, sign: sg }; + const sm = { phone_number: 'phoneNumber', job: 'job', employee_number: 'employeeNumber', address: 'address', mobile: 'mobile', email: 'email', organization: 'organization' }; + for (const [k, jk] of Object.entries(sm)) { const v = str(first(req?.[k])); if (v !== undefined) body[jk] = v; } + for (const k of ['is_mdm', 'weight']) { const v = num(first(req?.[k])); if (v !== undefined) body[k === 'is_mdm' ? 'isMdm' : k] = v; } + if (arr(req?.attrs).length > 0) body.attrs = req.attrs.map((a) => ({ attrKey: a?.attr_key ?? '', attrValue: a?.attr_value ?? '' })); + const j = await post(BASE + '/v1/updUser', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.4 DetailUser + const goDetailUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid); + const j = await post(BASE + '/v1/detailUser', { userId: uid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: j?.data ? mapDetail(j.data) : null }; + }; + + // 6.2.5 DelUsers + const goDelUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const tp = Number(first(req?.type) ?? 0); const uids = arr(first(req?.user_ids, req?.userIds)); + const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, did); + const body = { type: tp, orgCode: oc, appkey: apk, sign: sg }; + if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + const j = await post(BASE + '/v1/delUsers', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.6 StateUsers + const goStateUsers = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const tp = Number(first(req?.type) ?? 0); const st = first(req?.state); + if (st === undefined || st === null) throw er('INVALID_ARGUMENT', 'state required'); + const uids = arr(first(req?.user_ids, req?.userIds)); + const did = first(req?.condition?.dept_id, req?.condition?.deptId) || ''; + const sg = first(req?.sign) || signCalc(sk, apk, oc, uids.join(','), tp, st, did); + const body = { type: tp, state: st, orgCode: oc, appkey: apk, sign: sg }; + if (tp === 0) { body.userIds = uids; } else { const c = buildCond(req?.condition); if (Object.keys(c).length > 0) body.condition = c; } + const j = await post(BASE + '/v1/stateUsers', body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.7 CheckLoginName + const goCheckLoginName = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ln = first(req?.login_name, req?.loginName) || ''; if (!ln) throw er('INVALID_ARGUMENT', 'login_name required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ln); + const j = await post(BASE + '/v1/checkLoginName', { loginName: ln, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.8 GetUserByPhone + const goGetUserByPhone = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ph = first(req?.phone) || ''; if (!ph) throw er('INVALID_ARGUMENT', 'phone required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ph); + const j = await post(BASE + '/v1/getUserByPhone', { phone: ph, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: arr(j?.data).map(mapPhone) }; + }; + + // 6.2.9/6.2.10 UpdUserPwd + const goUpdUserPwd = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const ver = first(req?.version) || 'v1'; + let body; + if (ver === 'v2') { + const ln = first(req?.login_name, req?.loginName) || ''; const encryptedNewPwd = first(req?.new_pwd, req?.newPwd) || ''; + if (!ln) throw er('INVALID_ARGUMENT', 'login_name required for v2'); if (!encryptedNewPwd) throw er('INVALID_ARGUMENT', 'new_pwd required as 3DES-encrypted value for v2'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, ln, encryptedNewPwd); + body = { loginName: ln, newPwd: encryptedNewPwd, orgCode: oc, appkey: apk, sign: sg }; + const encryptedOldPwd = first(req?.old_pwd, req?.oldPwd); if (encryptedOldPwd) body.oldPwd = encryptedOldPwd; + } else { + const uid = first(req?.user_id, req?.userId) || ''; const encryptedPw = first(req?.password) || ''; + if (!uid) throw er('INVALID_ARGUMENT', 'user_id required for v1'); if (!encryptedPw) throw er('INVALID_ARGUMENT', 'password required as 3DES-encrypted value for v1'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid, encryptedPw); + body = { userId: uid, password: encryptedPw, orgCode: oc, appkey: apk, sign: sg }; + } + const j = await post(BASE + `/${ver}/updUserPwd`, body); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.11 ForceOffline + const goForceOffline = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const uid = first(req?.user_id, req?.userId) || ''; if (!uid) throw er('INVALID_ARGUMENT', 'user_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, uid); + const j = await post(BASE + '/v1/forceOffline', { userId: uid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + // 6.2.12 ImportUser + const goImportUser = async (req) => { + const oc = first(req?.org_code, req?.orgCode) || ocDef; const apk = first(req?.appkey) || ak; + const lang = first(req?.lang) ?? 0; const fid = first(req?.file_id, req?.fileId) || ''; + if (!fid) throw er('INVALID_ARGUMENT', 'file_id required'); + const sg = first(req?.sign) || signCalc(sk, apk, oc, lang, fid); + const j = await post(BASE + '/v1/importUser', { lang: Number(lang), fileId: fid, orgCode: oc, appkey: apk, sign: sg }); + return { code: j?.code ?? 0, msg: j?.msg ?? '', data: toValue(j?.data ?? null) }; + }; + + return { + [M.GetUsers]: async () => goGetUsers(ctx.req || {}), + [M.AddUser]: async () => goAddUser(ctx.req || {}), + [M.UpdUser]: async () => goUpdUser(ctx.req || {}), + [M.DetailUser]: async () => goDetailUser(ctx.req || {}), + [M.DelUsers]: async () => goDelUsers(ctx.req || {}), + [M.StateUsers]: async () => goStateUsers(ctx.req || {}), + [M.CheckLoginName]: async () => goCheckLoginName(ctx.req || {}), + [M.GetUserByPhone]: async () => goGetUserByPhone(ctx.req || {}), + [M.UpdUserPwd]: async () => goUpdUserPwd(ctx.req || {}), + [M.ForceOffline]: async () => goForceOffline(ctx.req || {}), + [M.ImportUser]: async () => goImportUser(ctx.req || {}), + }; +} + + +// Legacy handler wrapper +function wrapLegacyHandler(baseCtx, methodPath) { + return async function(reqOrCtx, maybeInnerCtx) { + var incoming = (reqOrCtx && typeof reqOrCtx === 'object') ? reqOrCtx : {}; + var callCtx = { + ...(baseCtx ?? {}), + ...incoming, + req: incoming.request ?? incoming.req ?? reqOrCtx ?? {}, + request: incoming.request ?? incoming.req ?? reqOrCtx ?? {}, + config: incoming.config ?? baseCtx?.config, + secret: incoming.secret ?? baseCtx?.secret, + metadata: incoming.metadata ?? baseCtx?.metadata, + meta: incoming.meta ?? baseCtx?.meta, + getMetadata: incoming.getMetadata ?? baseCtx?.getMetadata, + getMetadataAll: incoming.getMetadataAll ?? baseCtx?.getMetadataAll, + }; + return rpcdef(callCtx)[methodPath](); + }; +} + +function registerHandlers(ctx) { + return { + [M.GetUsers]: wrapLegacyHandler(ctx, M.GetUsers), + [M.AddUser]: wrapLegacyHandler(ctx, M.AddUser), + [M.UpdUser]: wrapLegacyHandler(ctx, M.UpdUser), + [M.DetailUser]: wrapLegacyHandler(ctx, M.DetailUser), + [M.DelUsers]: wrapLegacyHandler(ctx, M.DelUsers), + [M.StateUsers]: wrapLegacyHandler(ctx, M.StateUsers), + [M.CheckLoginName]: wrapLegacyHandler(ctx, M.CheckLoginName), + [M.GetUserByPhone]: wrapLegacyHandler(ctx, M.GetUserByPhone), + [M.UpdUserPwd]: wrapLegacyHandler(ctx, M.UpdUserPwd), + [M.ForceOffline]: wrapLegacyHandler(ctx, M.ForceOffline), + [M.ImportUser]: wrapLegacyHandler(ctx, M.ImportUser), + }; +} + +var sdkHandlers = registerHandlers({}); + +export var handlers = { + [M.GetUsers]: (ctx) => sdkHandlers[M.GetUsers](ctx), + [M.AddUser]: (ctx) => sdkHandlers[M.AddUser](ctx), + [M.UpdUser]: (ctx) => sdkHandlers[M.UpdUser](ctx), + [M.DetailUser]: (ctx) => sdkHandlers[M.DetailUser](ctx), + [M.DelUsers]: (ctx) => sdkHandlers[M.DelUsers](ctx), + [M.StateUsers]: (ctx) => sdkHandlers[M.StateUsers](ctx), + [M.CheckLoginName]: (ctx) => sdkHandlers[M.CheckLoginName](ctx), + [M.GetUserByPhone]: (ctx) => sdkHandlers[M.GetUserByPhone](ctx), + [M.UpdUserPwd]: (ctx) => sdkHandlers[M.UpdUserPwd](ctx), + [M.ForceOffline]: (ctx) => sdkHandlers[M.ForceOffline](ctx), + [M.ImportUser]: (ctx) => sdkHandlers[M.ImportUser](ctx), +}; diff --git a/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js new file mode 100644 index 00000000..cd00c03a --- /dev/null +++ b/services/zhizhangyi__mbs/test/zhizhangyi-mbs.test.js @@ -0,0 +1,187 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { GrpcError, grpcStatus } from '@chaitin-ai/octobus-sdk'; + +import { rpcdef } from '../src/zhizhangyi-mbs.js'; + +const ADD_USER = 'zhizhangyi.mbs.UserManagement/AddUser'; +const DEL_USERS = 'zhizhangyi.mbs.UserManagement/DelUsers'; +const GET_USERS = 'zhizhangyi.mbs.UserManagement/GetUsers'; +const STATE_USERS = 'zhizhangyi.mbs.UserManagement/StateUsers'; +const UPD_USER = 'zhizhangyi.mbs.UserManagement/UpdUser'; + +const originalFetch = globalThis.fetch; + +const buildCtx = (req) => ({ + config: { endpoint: 'https://mbs.example' }, + secret: { appkey: 'appkey', secretkey: 'secretkey', orgCode: 'org' }, + req, +}); + +test.afterEach(() => { + globalThis.fetch = originalFetch; +}); + +test('AddUser requires password before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept' }))[ADD_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /password required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('GetUsers preserves falsy state and is_mdm filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"total":0,"userInfos":[]}}' }; + }; + + await rpcdef(buildCtx({ condition: { state: 0, is_mdm: 0 } }))[GET_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/getUsers'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + deptId: '1', + keyWord: '', + state: 0, + isMdm: 0, + }); +}); + +test('AddUser forwards caller-provided 3DES-encrypted password value', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"created":true}}' }; + }; + + const result = await rpcdef(buildCtx({ user_name: 'User', login_name: 'user', dept_id: 'dept', password: '3des-ciphertext' }))[ADD_USER](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/addUser'); + assert.equal(JSON.parse(captured.init.body).password, '3des-ciphertext'); + assert.equal(result.data.structValue.fields.created.boolValue, true); +}); + +test('UpdUser requires dept_id before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ user_id: 'user-1', user_name: 'User' }))[UPD_USER](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /dept_id required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('StateUsers requires explicit state before calling upstream', async () => { + let called = false; + globalThis.fetch = async () => { + called = true; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await assert.rejects( + () => rpcdef(buildCtx({ type: 0, user_ids: ['user-1'] }))[STATE_USERS](), + (err) => { + assert.ok(err instanceof GrpcError); + assert.equal(err.code, grpcStatus.INVALID_ARGUMENT); + assert.equal(err.legacyCode, 'INVALID_ARGUMENT'); + assert.match(err.message, /state required/); + return true; + }, + ); + + assert.equal(called, false); +}); + +test('DelUsers treats string type zero as userIds mode', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await rpcdef(buildCtx({ type: '0', user_ids: ['user-1'] }))[DEL_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); + assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); + assert.equal(JSON.parse(captured.init.body).type, 0); + assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); +}); + +test('DelUsers condition mode preserves falsy condition filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":null}' }; + }; + + const result = await rpcdef(buildCtx({ type: 1, condition: { key_word: '', status: 0, is_mdm: 0, dept_id: 'dept' } }))[DEL_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/delUsers'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + keyWord: '', + status: 0, + isMdm: 0, + deptId: 'dept', + }); + assert.deepEqual(result.data, { nullValue: 'NULL_VALUE' }); +}); + +test('StateUsers condition mode preserves falsy condition filters', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0,"data":{"updated":2}}' }; + }; + + const result = await rpcdef(buildCtx({ type: 1, state: '0', condition: { status: 0, is_mdm: 0 } }))[STATE_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); + assert.equal(JSON.parse(captured.init.body).state, '0'); + assert.deepEqual(JSON.parse(captured.init.body).condition, { + status: 0, + isMdm: 0, + }); + assert.equal(result.data.structValue.fields.updated.numberValue, 2); +}); + +test('StateUsers treats string type zero as userIds mode', async () => { + let captured; + globalThis.fetch = async (url, init) => { + captured = { url, init }; + return { ok: true, status: 200, text: async () => '{"code":0}' }; + }; + + await rpcdef(buildCtx({ type: '0', state: '1', user_ids: ['user-1'] }))[STATE_USERS](); + + assert.equal(captured.url, 'https://mbs.example/uusafe/mos/thirdaccess/rest/opt/v1/stateUsers'); + assert.deepEqual(JSON.parse(captured.init.body).userIds, ['user-1']); + assert.equal(JSON.parse(captured.init.body).type, 0); + assert.equal(JSON.parse(captured.init.body).state, '1'); + assert.equal(Object.hasOwn(JSON.parse(captured.init.body), 'condition'), false); +}); diff --git a/services/zhizhangyi__mbs/test_octobus_mbs_client.go b/services/zhizhangyi__mbs/test_octobus_mbs_client.go new file mode 100644 index 00000000..56504cc7 --- /dev/null +++ b/services/zhizhangyi__mbs/test_octobus_mbs_client.go @@ -0,0 +1,449 @@ +package main + +import ( + "bytes" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +type clientConfig struct { + BaseURL string + Capset string + Instance string + Token string +} + +var cfg = clientConfig{ + BaseURL: "http://127.0.0.1:9000", + Capset: "mbs-scenarios", + Instance: "mbs-test", + Token: "", +} + +const ( + uid1 = "1691979294102310912" // test1 + uid2 = "1691979421122613248" // test2 +) + +var pass, failn int + +func gf(m map[string]any, k string) float64 { + f, _ := m[k].(float64) + return f +} + +func gs(m map[string]any, k string) string { + s, _ := m[k].(string) + return s +} + +func gm(m map[string]any, k string) map[string]any { + v, _ := m[k].(map[string]any) + return v +} + +func ok(s string, a ...any) { + pass++ + fmt.Printf(" PASS | "+s+"\n", a...) +} + +func fl(s string, a ...any) { + failn++ + fmt.Printf(" FAIL | "+s+"\n", a...) +} + +func inf(s string, a ...any) { + fmt.Printf(" INFO | "+s+"\n", a...) +} + +func scene(note string) { + fmt.Printf(" SCENE | %s\n", note) +} + +func hdr(s string) { + fmt.Printf("\n== %s ==\n", s) +} + +func methodURL(method string) string { + base := strings.TrimRight(cfg.BaseURL, "/") + return fmt.Sprintf("%s/capsets/%s/connect/%s/zhizhangyi.mbs.UserManagement/%s", base, cfg.Capset, cfg.Instance, method) +} + +func call(method string, body map[string]any) (map[string]any, int, error) { + buf, err := json.Marshal(body) + if err != nil { + return nil, 0, err + } + req, err := http.NewRequest(http.MethodPost, methodURL(method), bytes.NewReader(buf)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + if cfg.Token != "" { + req.Header.Set("Authorization", "Bearer "+cfg.Token) + } + cli := &http.Client{Timeout: 30 * time.Second} + resp, err := cli.Do(req) + if err != nil { + return nil, 0, err + } + defer resp.Body.Close() + raw, err := io.ReadAll(resp.Body) + if err != nil { + return nil, resp.StatusCode, err + } + var out map[string]any + if len(raw) == 0 { + out = map[string]any{} + } else if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{"raw": string(raw)}, resp.StatusCode, fmt.Errorf("decode response: %w", err) + } + if resp.StatusCode >= 300 { + return out, resp.StatusCode, fmt.Errorf("http=%d", resp.StatusCode) + } + if code, ok := out["code"].(string); ok && code != "" { + return out, resp.StatusCode, fmt.Errorf("connect_code=%s message=%s", code, gs(out, "message")) + } + return out, resp.StatusCode, nil +} + +func detail(uid string) (map[string]any, error) { + r, _, err := call("DetailUser", map[string]any{"userId": uid}) + return r, err +} + +func userDetail(uid string) (map[string]any, error) { + r, err := detail(uid) + if err != nil { + return nil, err + } + return gm(r, "data"), nil +} + +func scenario1() { + hdr("场景1: 停用 test2,不自动恢复") + scene("场景备注:模拟管理员手动停用 test2,执行后保留停用状态,方便去 MBS 前端观察启停变化") + _, _, err := call("StateUsers", map[string]any{"userIds": []string{uid2}, "type": 0, "state": "0"}) + if err != nil { + fl("StateUsers failed: %v", err) + return + } + ok("StateUsers returned success") + d, err := userDetail(uid2) + if err != nil { + fl("DetailUser verify failed: %v", err) + return + } + if gf(d, "state") == 0 { + ok("test2.state=0") + } else { + fl("test2.state=%.0f", gf(d, "state")) + } + inf("已停用 test2,当前脚本不会自动恢复,便于前端观察状态变化") +} + +func scenario2() { + hdr("场景2: 停用 test1,不自动恢复") + scene("场景备注:模拟管理员手动停用 test1,执行后保留停用状态,方便去 MBS 前端观察启停变化") + _, _, err := call("StateUsers", map[string]any{"userIds": []string{uid1}, "type": 0, "state": "0"}) + if err != nil { + fl("StateUsers failed: %v", err) + return + } + ok("StateUsers returned success") + d, err := userDetail(uid1) + if err != nil { + fl("DetailUser verify failed: %v", err) + return + } + if gf(d, "state") == 0 { + ok("test1.state=0") + } else { + fl("test1.state=%.0f", gf(d, "state")) + } + inf("已停用 test1,当前脚本不会自动恢复,便于前端观察状态变化") +} + +func scenario3() { + hdr("场景3: 开启 test1 设备管控") + scene("场景备注:模拟管理员给 test1 开启设备管控,用于前端观察 isMdm 或设备管理状态变化") + _, _, err := call("UpdUser", map[string]any{ + "userId": uid1, + "userName": "测试1", + "loginName": "test1", + "deptId": "1", + "isMdm": 1, + }) + if err != nil { + fl("UpdUser failed: %v", err) + return + } + ok("UpdUser returned success") + d, err := userDetail(uid1) + if err != nil { + fl("DetailUser verify failed: %v", err) + return + } + if gf(d, "isMdm") == 1 { + ok("test1.isMdm=1") + } else { + fl("test1.isMdm=%.0f", gf(d, "isMdm")) + } + inf("test1.state=%.0f", gf(d, "state")) +} + +func tGetUsers() { + hdr("6.2.1 GetUsers") + scene("场景备注:查询用户列表,适合先确认 OctoBus 到 MBS 的读取链路已经打通") + r, _, err := call("GetUsers", map[string]any{ + "index": 0, + "size": 10, + "condition": map[string]any{ + "deptId": "1", + "keyWord": "", + }, + }) + if err != nil { + fl("GetUsers failed: %v", err) + return + } + d := gm(r, "data") + inf("total=%.0f", gf(d, "total")) + if gf(d, "total") >= 2 { + ok("total>=2") + } else { + fl("total=%.0f", gf(d, "total")) + } +} + +func tAddUser() { + hdr("6.2.2 AddUser") + scene("场景备注:新增一个测试用户,适合验证创建类接口;执行前建议先确认账号命名规则和回收策略") + payload := map[string]any{ + "userName": "OctoBus新增用户", + "loginName": fmt.Sprintf("octobus_add_%d", time.Now().Unix()), + "deptId": "1", + "password": "123456", + "phoneNumber": "13800138111", + "userSource": 0, + "isMdm": 0, + "state": 1, + "organization": "OctoBus", + "employeeNumber": fmt.Sprintf("E%d", time.Now().Unix()%100000), + } + r, _, err := call("AddUser", payload) + if err != nil { + fl("AddUser failed: %v", err) + inf("payload=%s", mustJSON(payload)) + return + } + ok("AddUser returned success") + inf("response=%s", mustJSON(r)) +} + +func tUpdUser() { + hdr("6.2.3 UpdUser") + scene("场景备注:修改 test1 的基础字段,适合验证用户编辑能力是否可通过 OctoBus 正常下发") + _, _, err := call("UpdUser", map[string]any{ + "userId": uid1, + "userName": "测试1", + "loginName": "test1", + "deptId": "1", + "weight": 1, + }) + if err != nil { + fl("UpdUser failed: %v", err) + return + } + ok("UpdUser returned success") +} + +func tDetailUser() { + hdr("6.2.4 DetailUser") + scene("场景备注:查看 test1 当前详情,适合在做写操作前先确认用户当前状态") + r, _, err := call("DetailUser", map[string]any{"userId": uid1}) + if err != nil { + fl("DetailUser failed: %v", err) + return + } + d := gm(r, "data") + inf("loginName=%s state=%.0f isMdm=%.0f", gs(d, "loginName"), gf(d, "state"), gf(d, "isMdm")) + if gs(d, "loginName") == "test1" { + ok("loginName=test1") + } else { + fl("loginName=%s", gs(d, "loginName")) + } +} + +func tDelUsers() { + hdr("6.2.5 DelUsers") + scene("场景备注:删除指定测试用户,适合验证删除链路;执行前必须先把目标 userId 换成你自己的测试账号") + payload := map[string]any{"userIds": []string{"replace-with-user-id"}, "type": 0} + r, _, err := call("DelUsers", payload) + if err != nil { + fl("DelUsers failed: %v", err) + inf("payload=%s", mustJSON(payload)) + return + } + ok("DelUsers returned success") + inf("response=%s", mustJSON(r)) +} + +func tStateUsers() { + hdr("6.2.6 StateUsers") + scene("场景备注:演示用户启停接口,这里默认把 test2 设为启用,可作为停用后的恢复动作") + _, _, err := call("StateUsers", map[string]any{"userIds": []string{uid2}, "type": 0, "state": "1"}) + if err != nil { + fl("StateUsers failed: %v", err) + return + } + ok("StateUsers returned success") +} + +func tCheckLoginName() { + hdr("6.2.7 CheckLoginName") + scene("场景备注:同时验证一个已存在账号和一个新账号,适合检查业务错误映射和可用性判断") + r1, _, err1 := call("CheckLoginName", map[string]any{"loginName": "test1"}) + if err1 != nil { + inf("test1 occupied, resp=%s err=%v", mustJSON(r1), err1) + ok("existing login name returned business error as expected") + } else { + fl("test1 unexpectedly passed, resp=%s", mustJSON(r1)) + } + r2, _, err2 := call("CheckLoginName", map[string]any{"loginName": fmt.Sprintf("octobus_free_%d", time.Now().Unix())}) + if err2 != nil { + fl("new login name failed: %v", err2) + return + } + ok("new login name returned success") + inf("response=%s", mustJSON(r2)) +} + +func tGetUserByPhone() { + hdr("6.2.8 GetUserByPhone") + scene("场景备注:按手机号查询用户,适合验证手机号索引查询能力是否正常") + r, _, err := call("GetUserByPhone", map[string]any{"phone": "13800138000"}) + if err != nil { + fl("GetUserByPhone failed: %v", err) + return + } + ok("GetUserByPhone returned success") + inf("response=%s", mustJSON(r)) +} + +func tUpdUserPwd() { + hdr("6.2.9/6.2.10 UpdUserPwd") + scene("场景备注:修改用户密码,适合验证敏感写操作;执行前要先准备好 3DES 加密后的密码串") + payload := map[string]any{ + "userId": uid1, + "password": "replace-with-3des-password", + "version": "v1", + } + r, _, err := call("UpdUserPwd", payload) + if err != nil { + fl("UpdUserPwd failed: %v", err) + inf("payload=%s", mustJSON(payload)) + return + } + ok("UpdUserPwd returned success") + inf("response=%s", mustJSON(r)) +} + +func tForceOffline() { + hdr("6.2.11 ForceOffline") + scene("场景备注:强制指定用户下线,适合验证在线会话踢出能力;前提是目标用户当前在线") + r, _, err := call("ForceOffline", map[string]any{"userId": uid1}) + if err != nil { + fl("ForceOffline failed: %v", err) + return + } + ok("ForceOffline returned success") + inf("response=%s", mustJSON(r)) +} + +func tImportUser() { + hdr("6.2.12 ImportUser") + scene("场景备注:通过 fileId 导入用户,适合验证批量导入能力;前提是你已经先完成文件上传") + payload := map[string]any{"lang": 2, "fileId": "replace-with-uploaded-file-id"} + r, _, err := call("ImportUser", payload) + if err != nil { + fl("ImportUser failed: %v", err) + inf("payload=%s", mustJSON(payload)) + return + } + ok("ImportUser returned success") + inf("response=%s", mustJSON(r)) +} + +func mustJSON(v any) string { + b, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("marshal-error:%v", err) + } + return string(b) +} + +func main() { + baseURL := flag.String("base-url", cfg.BaseURL, "OctoBus base URL") + capset := flag.String("capset", cfg.Capset, "OctoBus capset ID") + instance := flag.String("instance", cfg.Instance, "OctoBus instance ID") + token := flag.String("token", cfg.Token, "OctoBus bearer token") + tn := flag.String("test", "all", "test name: all|scenarios|apis|scenario1|getUsers|...") + flag.Parse() + + cfg.BaseURL = *baseURL + cfg.Capset = *capset + cfg.Instance = *instance + cfg.Token = *token + + all := map[string]func(){ + "scenario1": scenario1, + "scenario2": scenario2, + "scenario3": scenario3, + "getUsers": tGetUsers, + "addUser": tAddUser, + "updUser": tUpdUser, + "detailUser": tDetailUser, + "delUsers": tDelUsers, + "stateUsers": tStateUsers, + "checkLoginName": tCheckLoginName, + "getUserByPhone": tGetUserByPhone, + "updUserPwd": tUpdUserPwd, + "forceOffline": tForceOffline, + "importUser": tImportUser, + } + + run := func(keys []string) { + for _, k := range keys { + all[k]() + } + } + + switch *tn { + case "scenarios": + run([]string{"scenario1", "scenario2", "scenario3"}) + case "apis": + run([]string{"getUsers", "addUser", "updUser", "detailUser", "delUsers", "stateUsers", "checkLoginName", "getUserByPhone", "updUserPwd", "forceOffline", "importUser"}) + case "all": + run([]string{"scenario1", "scenario2", "scenario3", "getUsers", "addUser", "updUser", "detailUser", "delUsers", "stateUsers", "checkLoginName", "getUserByPhone", "updUserPwd", "forceOffline", "importUser"}) + default: + fn, ok := all[*tn] + if !ok { + fmt.Fprintf(os.Stderr, "unknown test: %s\n", *tn) + os.Exit(1) + } + fn() + } + + fmt.Printf("\n===================================\n") + fmt.Printf("Results: %d passed, %d failed\n", pass, failn) + if failn > 0 { + os.Exit(1) + } +} diff --git a/services/zhizhangyi__mbs/testdata/test_4apis.mjs b/services/zhizhangyi__mbs/testdata/test_4apis.mjs new file mode 100644 index 00000000..1a7fe25f --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_4apis.mjs @@ -0,0 +1,130 @@ +// MBS API 测试: getUsers / detailUser / checkLoginName / stateUsers +import crypto from 'node:crypto'; +import https from 'node:https'; + +const C = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; +const requireConfig = () => { + const missing = Object.entries(C).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; +requireConfig(); +const BASE = `${C.baseUrl}/uusafe/mos/thirdaccess/rest/opt`; +const agent = new https.Agent({ rejectUnauthorized: false }); +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const sign = (...ps) => md5(ps.map((p) => (p ?? '')).join('') + C.secretkey); + +const post = async (path, body) => { + const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), agent }); + const text = await res.text(); + return { status: res.status, json: JSON.parse(text) }; +}; + +(async () => { + const { appkey, orgCode } = C; + + // ── Test 1: getUsers ── + console.log('══════════════════════════════════════════'); + console.log('Test 1: getUsers (用户列表)'); + console.log('══════════════════════════════════════════'); + try { + const r1 = await post('/v1/getUsers', { + index: 0, size: 10, orderCode: 0, orderType: 1, + condition: { deptId: '1', keyWord: '' }, + orgCode, appkey, + sign: sign(appkey, orgCode, 0, 10, 0, 1, '', '', '', '1'), + }); + console.log(`HTTP ${r1.status} code=${r1.json.code}`); + if (r1.json.code === 0) { + console.log(` 共 ${r1.json.data.total} 个用户`); + (r1.json.data.userInfos || []).forEach((u, i) => + console.log(` [${i + 1}] ${u.userName} (${u.loginName}) userId=${u.userId} state=${u.state}`)); + } else { console.log(` ❌ ${r1.json.msg}`); } + } catch (e) { console.log(` ❌ ${e.message}`); } + + // ── Test 2: detailUser (test1) ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 2: detailUser (用户详情 - test1)'); + console.log('══════════════════════════════════════════'); + const uid = '1691979294102310912'; // test1 + try { + const r2 = await post('/v1/detailUser', { + userId: uid, orgCode, appkey, + sign: sign(appkey, orgCode, uid), + }); + console.log(`HTTP ${r2.status} code=${r2.json.code}`); + if (r2.json.code === 0) { + const d = r2.json.data; + console.log(` ID: ${d.userId}`); + console.log(` 姓名: ${d.userName}`); + console.log(` 登录名: ${d.loginName}`); + console.log(` 部门: ${d.deptName} (${d.deptId})`); + console.log(` 手机: ${d.phoneNumber || '(空)'}`); + console.log(` 邮箱: ${d.email || '(空)'}`); + console.log(` 状态: ${d.state === 1 ? '启用' : d.state === 0 ? '停用' : '未激活'}`); + console.log(` 设备管理: ${d.isMdm ? '开启' : '关闭'}`); + } else { console.log(` ❌ ${r2.json.msg}`); } + } catch (e) { console.log(` ❌ ${e.message}`); } + + // ── Test 3: checkLoginName ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 3: checkLoginName (账号校验)'); + console.log('══════════════════════════════════════════'); + for (const name of ['test1', 'nonexistent_user']) { + try { + const r3 = await post('/v1/checkLoginName', { + loginName: name, orgCode, appkey, + sign: sign(appkey, orgCode, name), + }); + const ok = r3.json.code === 0; + console.log(` "${name}" -> HTTP ${r3.status} code=${r3.json.code} ${ok ? '✅ 可用' : '❌ ' + r3.json.msg}`); + } catch (e) { console.log(` "${name}" -> ❌ ${e.message}`); } + } + + // ── Test 4: stateUsers (启停 - 先停用 test2 再启用) ── + console.log('\n══════════════════════════════════════════'); + console.log('Test 4: stateUsers (用户启停 - test2)'); + console.log('══════════════════════════════════════════'); + const uid2 = '1691979421122613248'; // test2 + + // 4a: 停用 + console.log(' 4a: 停用 test2...'); + try { + const r4a = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '0', ''), + }); + console.log(` HTTP ${r4a.status} code=${r4a.json.code} ${r4a.json.code === 0 ? '✅ 停用成功' : '❌ ' + r4a.json.msg}`); + } catch (e) { console.log(` ❌ ${e.message}`); } + + // 4b: 验证 - 查详情看 state + try { + const r4b = await post('/v1/detailUser', { + userId: uid2, orgCode, appkey, + sign: sign(appkey, orgCode, uid2), + }); + console.log(` 4b: 验证停用后详情 state=${r4b.json.data?.state} ${r4b.json.data?.state === 0 ? '✅ 已停用' : '⚠️'}`); + } catch (e) { console.log(` 4b: ❌ ${e.message}`); } + + // 4c: 重新启用 + console.log(' 4c: 重新启用 test2...'); + try { + const r4c = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '1', ''), + }); + console.log(` HTTP ${r4c.status} code=${r4c.json.code} ${r4c.json.code === 0 ? '✅ 重新启用成功' : '❌ ' + r4c.json.msg}`); + } catch (e) { console.log(` ❌ ${e.message}`); } + + console.log('\n══════════════════════════════════════════'); + console.log('全部测试完成'); + console.log('══════════════════════════════════════════'); +})(); diff --git a/services/zhizhangyi__mbs/testdata/test_getusers.mjs b/services/zhizhangyi__mbs/testdata/test_getusers.mjs new file mode 100644 index 00000000..a1e4d0ff --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_getusers.mjs @@ -0,0 +1,104 @@ +// MBS getUsers API 直连测试 +// 不依赖 OctoBus SDK,直接用 Node.js 原生 fetch 调用 + +import crypto from 'node:crypto'; +import https from 'node:https'; + +const CONFIG = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; + +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); + +// sign = MD5(appkey + orgCode + index + size + orderCode + orderType + keyword + state + isMdm + deptId + secretkey) +// 拼接时不包含 "+" +const computeSign = (params) => { + const joined = params.map((p) => (p === undefined || p === null ? '' : String(p))).join(''); + return md5(joined + CONFIG.secretkey); +}; + +const requireConfig = () => { + const missing = Object.entries(CONFIG).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; + +const testGetUsers = async () => { + requireConfig(); + const { baseUrl, orgCode, appkey } = CONFIG; + + const index = 0; + const size = 10; + const orderCode = 0; + const orderType = 1; + const keyword = ''; + const state = ''; + const isMdm = ''; + const deptId = '1'; + + const sign = computeSign([appkey, orgCode, index, size, orderCode, orderType, keyword, state, isMdm, deptId]); + + const body = { + index, + size, + orderCode, + orderType, + condition: { deptId, keyWord: keyword }, + orgCode, + appkey, + sign, + }; + + console.log('=== MBS getUsers 测试 ==='); + console.log('URL:', `${baseUrl}/uusafe/mos/thirdaccess/rest/opt/v1/getUsers`); + console.log('Body:', JSON.stringify(body, null, 2)); + console.log('Sign:', sign); + console.log(''); + + // 忽略自签名证书 + const agent = new https.Agent({ rejectUnauthorized: false }); + + try { + const res = await fetch(`${baseUrl}/uusafe/mos/thirdaccess/rest/opt/v1/getUsers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + body: JSON.stringify(body), + agent, + }); + + const text = await res.text(); + console.log('HTTP Status:', res.status); + console.log('Response headers:', Object.fromEntries(res.headers.entries())); + + let json; + try { + json = JSON.parse(text); + console.log('\n✅ 响应 JSON:'); + console.log(JSON.stringify(json, null, 2)); + + if (json.code === 0) { + console.log(`\n✅ 成功!共 ${json.data?.total ?? 0} 个用户`); + const users = json.data?.userInfos; + if (Array.isArray(users)) { + users.forEach((u, i) => { + console.log(` [${i + 1}] ${u.userName} (${u.loginName}) - ${u.deptName} - ${u.state === 1 ? '启用' : u.state === 0 ? '停用' : '未激活'}`); + }); + } + } else { + console.log(`\n❌ MBS 返回错误: code=${json.code}, msg=${json.msg}`); + } + } catch { + console.log('\n⚠️ 非 JSON 响应:'); + console.log(text.substring(0, 500)); + } + } catch (e) { + console.log('\n❌ 网络错误:', e.message); + if (e.cause) console.log(' 原因:', e.cause.message); + } +}; + +testGetUsers(); diff --git a/services/zhizhangyi__mbs/testdata/test_mbs_client.go b/services/zhizhangyi__mbs/testdata/test_mbs_client.go new file mode 100644 index 00000000..19bdb531 --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_mbs_client.go @@ -0,0 +1,293 @@ +// MBS API Test Client (Go) - 直接调用 MBS REST API +// 用法见 HELP.md +package main + +import ( + "bytes" + "crypto/md5" + "crypto/tls" + "encoding/json" + "flag" + "fmt" + "io" + "net/http" + "os" + "strings" + "time" +) + +var cfg = struct{ B, O, A, S string }{ + B: envOr("MBS_BASE_URL", "https://127.0.0.1:9074"), + O: os.Getenv("MBS_ORG_CODE"), + A: os.Getenv("MBS_APPKEY"), + S: os.Getenv("MBS_SECRETKEY"), +} + +const apiBase = "/uusafe/mos/thirdaccess/rest/opt" +const uid1 = "1691979294102310912" // test1 +const uid2 = "1691979421122613248" // test2 + +var pass, failn, skipn int + +func envOr(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} + +func requireConfig() { + var missing []string + for key, value := range map[string]string{"MBS_ORG_CODE": cfg.O, "MBS_APPKEY": cfg.A, "MBS_SECRETKEY": cfg.S} { + if value == "" { + missing = append(missing, key) + } + } + if len(missing) > 0 { + fmt.Fprintf(os.Stderr, "missing MBS test config: %s; set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY\n", strings.Join(missing, ", ")) + os.Exit(2) + } +} + +func md5s(s string) string { h := md5.Sum([]byte(s)); return fmt.Sprintf("%x", h) } +func sign(p ...string) string { return md5s(strings.Join(p, "") + cfg.S) } +func gf(m map[string]interface{}, k string) float64 { f, _ := m[k].(float64); return f } +func gs(m map[string]interface{}, k string) string { s, _ := m[k].(string); return s } +func ok(s string, a ...interface{}) { pass++; fmt.Printf(" ✅ PASS | "+s+"\n", a...) } +func fl(s string, a ...interface{}) { failn++; fmt.Printf(" ❌ FAIL | "+s+"\n", a...) } +func sk(s string, a ...interface{}) { skipn++; fmt.Printf(" ⏭️ SKIP | "+s+"\n", a...) } +func inf(s string, a ...interface{}) { fmt.Printf(" ℹ️ "+s+"\n", a...) } +func hdr(s string) { fmt.Printf("\n── %s ──\n", s) } + +func call(path string, body map[string]interface{}) (map[string]interface{}, error) { + b, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", cfg.B+apiBase+path, bytes.NewReader(b)) + req.Header.Set("Content-Type", "application/json; charset=utf-8") + cl := &http.Client{Timeout: 30 * time.Second, + Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}}} + resp, err := cl.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + rb, _ := io.ReadAll(resp.Body) + var r map[string]interface{} + json.Unmarshal(rb, &r) + if c, _ := r["code"].(float64); c != 0 { + return r, fmt.Errorf("code=%.0f", c) + } + return r, nil +} + +func detail(uid string) (map[string]interface{}, error) { + return call("/v1/detailUser", map[string]interface{}{ + "userId": uid, "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid)}) +} + +// ═══ 场景1: test2 停用 (state=0) ═══ +func s1() { + hdr("场景1: test2 用户停用 (state=0)") + inf("接口:stateUsers | 入参:userIds=[uid2],type=0,state=0 | 目标:code=0, state=0") + _, e := call("/v1/stateUsers", map[string]interface{}{ + "userIds": []string{uid2}, "type": 0, "state": "0", + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid2, "0", "0", "")}) + if e != nil { + fl("失败:%v", e) + return + } + ok("code=0") + da, _ := detail(uid2) + d := da["data"].(map[string]interface{}) + if gf(d, "state") == 0 { + ok("test2.state=0 已停用") + } else { + fl("state=%.0f", gf(d, "state")) + } + call("/v1/stateUsers", map[string]interface{}{ + "userIds": []string{uid2}, "type": 0, "state": "1", + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid2, "0", "1", "")}) + inf("已恢复 test2") +} + +// ═══ 场景2: test1 停用 (state=0) ═══ +func s2() { + hdr("场景2: test1 停用 (state=0)") + inf("接口:stateUsers | 入参:userIds=[uid1],type=0,state=0 | 目标:code=0, state=0") + _, e := call("/v1/stateUsers", map[string]interface{}{ + "userIds": []string{uid1}, "type": 0, "state": "0", + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid1, "0", "0", "")}) + if e != nil { + fl("失败:%v", e) + return + } + ok("code=0") + da, _ := detail(uid1) + d := da["data"].(map[string]interface{}) + if gf(d, "state") == 0 { + ok("test1.state=0 已停用") + } else { + fl("state=%.0f", gf(d, "state")) + } + call("/v1/stateUsers", map[string]interface{}{ + "userIds": []string{uid1}, "type": 0, "state": "1", + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid1, "0", "1", "")}) + inf("已恢复 test1") +} + +// ═══ 场景3: test1 开启设备管控 (isMdm=1) ═══ +func s3() { + hdr("场景3: test1 开启设备管控 (isMdm=1)") + inf("接口:updUser | 入参:userId=uid1,isMdm=1 | 目标:code=0,isMdm=1,state保持1") + _, e := call("/v1/updUser", map[string]interface{}{ + "userId": uid1, "userName": "测试1", "loginName": "test1", "deptId": "1", + "isMdm": 1, "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid1, "测试1", "test1", "1")}) + if e != nil { + fl("失败:%v", e) + return + } + ok("code=0") + da, _ := detail(uid1) + d := da["data"].(map[string]interface{}) + if gf(d, "isMdm") == 1 { + ok("isMdm=1 设备管控已开启") + } else { + fl("isMdm=%.0f", gf(d, "isMdm")) + } + inf("state=%.0f (应保持1)", gf(d, "state")) +} + +// 6.2.1 GetUsers +func tGetUsers() { + hdr("6.2.1 GetUsers - 用户列表") + inf("入参:index=0,size=10 | 目标:total>=2") + r, e := call("/v1/getUsers", map[string]interface{}{ + "index": 0, "size": 10, "orderCode": 0, "orderType": 1, + "condition": map[string]interface{}{"deptId": "1", "keyWord": ""}, + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, "0", "10", "0", "1", "", "", "", "1")}) + if e != nil { + fl("失败:%v", e) + return + } + d := r["data"].(map[string]interface{}) + inf("total=%.0f", gf(d, "total")) + if gf(d, "total") >= 2 { + ok("total>=2") + } else { + fl("total=%.0f", gf(d, "total")) + } +} + +// 6.2.4 DetailUser +func tDetailUser() { + hdr("6.2.4 DetailUser - 用户详情") + r, e := detail(uid1) + if e != nil { + fl("失败:%v", e) + return + } + d := r["data"].(map[string]interface{}) + inf("userName=%s state=%.0f isMdm=%.0f", gs(d, "userName"), gf(d, "state"), gf(d, "isMdm")) + if gs(d, "loginName") == "test1" { + ok("loginName=test1") + } else { + fl("loginName=%s", gs(d, "loginName")) + } +} + +// 6.2.6 StateUsers +func tStateUsers() { + hdr("6.2.6 StateUsers - 启停用户") + _, e := call("/v1/stateUsers", map[string]interface{}{ + "userIds": []string{uid2}, "type": 0, "state": "1", + "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid2, "0", "1", "")}) + if e != nil { + fl("失败:%v", e) + } else { + ok("code=0") + } +} + +// 6.2.7 CheckLoginName +func tCheckLoginName() { + hdr("6.2.7 CheckLoginName - 账号校验") + inf("test1(已存在)→存在 | nonexistent_user→可用") + r2, _ := call("/v1/checkLoginName", map[string]interface{}{ + "loginName": "test1", "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, "test1")}) + inf("test1: code=%.0f msg=%s", gf(r2, "code"), gs(r2, "msg")) + _, e := call("/v1/checkLoginName", map[string]interface{}{ + "loginName": "nonexistent_user", "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, "nonexistent_user")}) + if e != nil { + fl("失败:%v", e) + } else { + ok("nonexistent_user 可用") + } +} + +// 6.2.8 GetUserByPhone +func tGetUserByPhone() { + hdr("6.2.8 GetUserByPhone - 手机号查询") + _, e := call("/v1/getUserByPhone", map[string]interface{}{ + "phone": "13800138000", "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, "13800138000")}) + if e != nil { + fl("失败:%v", e) + } else { + ok("code=0") + } +} + +// 6.2.3 UpdUser +func tUpdUser() { + hdr("6.2.3 UpdUser - 编辑用户") + _, e := call("/v1/updUser", map[string]interface{}{ + "userId": uid1, "userName": "测试1", "loginName": "test1", "deptId": "1", + "weight": 1, "orgCode": cfg.O, "appkey": cfg.A, "sign": sign(cfg.A, cfg.O, uid1, "测试1", "test1", "1")}) + if e != nil { + fl("失败:%v", e) + } else { + ok("code=0") + } +} + +// 其余只写/危险操作跳过 +func tAddUser() { hdr("6.2.2 AddUser"); sk("生产环境跳过写操作") } +func tDelUsers() { hdr("6.2.5 DelUsers"); sk("生产环境跳过写操作") } +func tUpdUserPwd() { hdr("6.2.9/6.2.10 UpdUserPwd"); sk("生产环境跳过写操作") } +func tForceOffline() { hdr("6.2.11 ForceOffline"); sk("test1无在线会话") } +func tImportUser() { hdr("6.2.12 ImportUser"); sk("需上传文件") } + +func main() { + requireConfig() + tn := flag.String("test", "all", "test name: all|scenarios|apis|scenario1|getUsers|...") + flag.Parse() + all := map[string]func(){ + "scenario1": s1, "scenario2": s2, "scenario3": s3, + "getUsers": tGetUsers, "addUser": tAddUser, "updUser": tUpdUser, + "detailUser": tDetailUser, "delUsers": tDelUsers, "stateUsers": tStateUsers, + "checkLoginName": tCheckLoginName, "getUserByPhone": tGetUserByPhone, + "updUserPwd": tUpdUserPwd, "forceOffline": tForceOffline, "importUser": tImportUser} + run := func(keys []string) { + for _, k := range keys { + all[k]() + } + } + switch *tn { + case "scenarios": + run([]string{"scenario1", "scenario2", "scenario3"}) + case "apis": + run([]string{"getUsers", "addUser", "updUser", "detailUser", "delUsers", "stateUsers", "checkLoginName", "getUserByPhone", "updUserPwd", "forceOffline", "importUser"}) + case "all": + run([]string{"scenario1", "scenario2", "scenario3", "getUsers", "addUser", "updUser", "detailUser", "delUsers", "stateUsers", "checkLoginName", "getUserByPhone", "updUserPwd", "forceOffline", "importUser"}) + default: + if fn, ok := all[*tn]; ok { + fn() + } else { + fmt.Fprintf(os.Stderr, "unknown test: %s\n", *tn) + os.Exit(1) + } + } + fmt.Printf("\n═══════════════════════════════════\n") + fmt.Printf("Results: %d passed, %d failed, %d skipped\n", pass, failn, skipn) + if failn > 0 { + os.Exit(1) + } +} diff --git a/services/zhizhangyi__mbs/testdata/test_scenarios.mjs b/services/zhizhangyi__mbs/testdata/test_scenarios.mjs new file mode 100644 index 00000000..e01affb8 --- /dev/null +++ b/services/zhizhangyi__mbs/testdata/test_scenarios.mjs @@ -0,0 +1,101 @@ +// MBS 场景测试: test2停用 / test1设备激活停用 / test1开启设备管控 +import crypto from 'node:crypto'; +import https from 'node:https'; + +const C = { + baseUrl: process.env.MBS_BASE_URL || 'https://127.0.0.1:9074', + orgCode: process.env.MBS_ORG_CODE || '', + appkey: process.env.MBS_APPKEY || '', + secretkey: process.env.MBS_SECRETKEY || '', +}; +const requireConfig = () => { + const missing = Object.entries(C).filter(([, value]) => !value).map(([key]) => key); + if (missing.length > 0) { + throw new Error(`Missing MBS test config: ${missing.join(', ')}. Set MBS_BASE_URL, MBS_ORG_CODE, MBS_APPKEY and MBS_SECRETKEY.`); + } +}; +requireConfig(); +const BASE = `${C.baseUrl}/uusafe/mos/thirdaccess/rest/opt`; +const agent = new https.Agent({ rejectUnauthorized: false }); +const md5 = (s) => crypto.createHash('md5').update(s, 'utf8').digest('hex'); +const sign = (...ps) => md5(ps.map((p) => (p ?? '')).join('') + C.secretkey); + +const post = async (path, body) => { + const res = await fetch(`${BASE}${path}`, { method: 'POST', headers: { 'Content-Type': 'application/json; charset=utf-8' }, body: JSON.stringify(body), agent }); + const text = await res.text(); + return { status: res.status, json: JSON.parse(text) }; +}; + +const { appkey, orgCode } = C; +const uid1 = '1691979294102310912'; // test1 +const uid2 = '1691979421122613248'; // test2 + +// 辅助: 查详情 +const detail = async (uid) => { + const r = await post('/v1/detailUser', { userId: uid, orgCode, appkey, sign: sign(appkey, orgCode, uid) }); + return r.json?.data; +}; + +(async () => { + // 先查初始状态 + console.log('=== 初始状态 ==='); + let d1 = await detail(uid1); + let d2 = await detail(uid2); + console.log(`test1: state=${d1.state} isMdm=${d1.isMdm}`); + console.log(`test2: state=${d2.state} isMdm=${d2.isMdm}`); + + // ═══ 场景1: test2 用户状态 → 停用 (state=0) ═══ + console.log('\n═══ 场景1: test2 用户状态 → 停用 (state=0) ═══'); + const r1 = await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '0', ''), + }); + console.log(` stateUsers -> HTTP ${r1.status} code=${r1.json.code} ${r1.json.code === 0 ? '✅' : '❌'}`); + d2 = await detail(uid2); + console.log(` 验证: test2 state=${d2.state} ${d2.state === 0 ? '✅ 已停用' : '⚠️'}`); + + // 恢复 test2 + await post('/v1/stateUsers', { + userIds: [uid2], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid2, 0, '1', ''), + }); + + // ═══ 场景2: test1 设备激活状态 → 停用 (state=0) ═══ + console.log('\n═══ 场景2: test1 设备激活状态 → 停用 (state=0) ═══'); + const r2 = await post('/v1/stateUsers', { + userIds: [uid1], type: 0, state: '0', + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, 0, '0', ''), + }); + console.log(` stateUsers -> HTTP ${r2.status} code=${r2.json.code} ${r2.json.code === 0 ? '✅' : '❌'}`); + d1 = await detail(uid1); + console.log(` 验证: test1 state=${d1.state} ${d1.state === 0 ? '✅ 已停用' : '⚠️'}`); + + // 恢复 test1 + await post('/v1/stateUsers', { + userIds: [uid1], type: 0, state: '1', + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, 0, '1', ''), + }); + + // ═══ 场景3: test1 开启设备管控 (isMdm=1) ═══ + console.log('\n═══ 场景3: test1 开启设备管控 (isMdm=1) ═══'); + console.log(' 调用 updUser 设置 isMdm=1 ...'); + const r3 = await post('/v1/updUser', { + userId: uid1, + userName: '测试1', + loginName: 'test1', + deptId: '1', + isMdm: 1, + orgCode, appkey, + sign: sign(appkey, orgCode, uid1, '测试1', 'test1', '1'), + }); + console.log(` updUser -> HTTP ${r3.status} code=${r3.json.code} ${r3.json.code === 0 ? '✅' : '❌ ' + r3.json.msg}`); + d1 = await detail(uid1); + console.log(` 验证: test1 isMdm=${d1.isMdm} ${d1.isMdm === 1 ? '✅ 设备管控已开启' : '⚠️'}`); + console.log(` test1 state=${d1.state} (应仍为1-启用)`); + + console.log('\n=== 全部场景测试完成 ==='); +})();