From 20b5daba50da68cae1fed449b88db6792c1ee2c4 Mon Sep 17 00:00:00 2001 From: kuaifan Date: Mon, 1 Jun 2026 01:26:34 +0000 Subject: [PATCH] =?UTF-8?q?feat(manage):=20=E5=9B=A2=E9=98=9F=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=94=AF=E6=8C=81=E7=AE=A1=E7=90=86=E5=91=98=E5=88=9B?= =?UTF-8?q?=E5=BB=BA/=E6=89=B9=E9=87=8F=E5=AF=BC=E5=85=A5=E5=91=98?= =?UTF-8?q?=E5=B7=A5=E8=B4=A6=E5=8F=B7=EF=BC=88=E5=90=AB=E9=83=A8=E9=97=A8?= =?UTF-8?q?=E3=80=81=E8=81=8C=E4=BD=8D=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。 批量导入:上传 Excel/CSV → 预览逐行校验 → 确认后导入。职位为模板第4列(选填,逐行解析校验),部门在预览表按行勾选后由底部设置部门到选中写入;导入按行返回结果(全成功关弹窗+成功提示;含失败留弹窗显示失败明细;仅 success>0 才刷新列表)。 后端:User::createByAdmin 选项数组化 + 校验助手 assertValidProfession/assertValidDepartments;importUsers 逐行 department/profession;UsersController createuser/import;UserImport/UserImportTemplate(含职位列)。 测试:tests/Feature/AdminCreateUserTest、tests/Unit/UserImportParseTest。 Co-Authored-By: Claude Opus 4.8 --- app/Http/Controllers/Api/UsersController.php | 97 ++++- app/Models/User.php | 279 +++++++++++++ app/Module/UserImport.php | 13 + app/Module/UserImportTemplate.php | 21 + language/original-api.txt | 11 + language/original-web.txt | 28 ++ .../manage/components/CreateUserModal.vue | 143 +++++++ .../manage/components/ImportUserModal.vue | 365 ++++++++++++++++++ .../manage/components/TeamManagement.vue | 12 +- .../pages/manage/components/UserEditModal.vue | 6 + tests/Feature/AdminCreateUserTest.php | 187 +++++++++ tests/Unit/UserImportParseTest.php | 137 +++++++ 12 files changed, 1297 insertions(+), 2 deletions(-) create mode 100644 app/Module/UserImport.php create mode 100644 app/Module/UserImportTemplate.php create mode 100644 resources/assets/js/pages/manage/components/CreateUserModal.vue create mode 100644 resources/assets/js/pages/manage/components/ImportUserModal.vue create mode 100644 tests/Feature/AdminCreateUserTest.php create mode 100644 tests/Unit/UserImportParseTest.php diff --git a/app/Http/Controllers/Api/UsersController.php b/app/Http/Controllers/Api/UsersController.php index 8c670b3ed..8479b4a27 100755 --- a/app/Http/Controllers/Api/UsersController.php +++ b/app/Http/Controllers/Api/UsersController.php @@ -41,6 +41,9 @@ use Illuminate\Support\Facades\DB; use App\Models\UserEmailVerification; use App\Module\AgoraIO\AgoraTokenGenerator; use Swoole\Coroutine; +use App\Module\UserImport; +use App\Module\UserImportTemplate; +use Maatwebsite\Excel\Facades\Excel; /** * @apiDefine users @@ -1263,7 +1266,7 @@ class UsersController extends AbstractController User::passwordPolicy($password); $upArray['encrypt'] = Base::generatePassword(6); $upArray['password'] = Doo::md5s($password, $upArray['encrypt']); - $upArray['changepass'] = 1; + $upArray['changepass'] = intval($data['changepass'] ?? 1) === 1 ? 1 : 0; $upLdap['userPassword'] = $password; } // 昵称 @@ -1340,6 +1343,98 @@ class UsersController extends AbstractController return Base::retSuccess($msg, $userInfo); } + /** + * @api {post} api/users/createuser 创建用户(管理员) + * + * @apiDescription 需要token身份(管理员) + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName createuser + * + * @apiParam {String} email 邮箱 + * @apiParam {String} password 初始密码 + * @apiParam {String} nickname 昵称 + * @apiParam {String} [profession] 职位/职称(可选,2-20字) + * @apiParam {Array} [department] 部门ID列表(可选,最多10个) + */ + public function createuser() + { + User::auth('admin'); + $email = trim(Request::input('email')); + $password = trim(Request::input('password')); + $nickname = trim(Request::input('nickname')); + $changePass = intval(Request::input('changepass', 1)) === 1; + $profession = trim((string)Request::input('profession', '')); + $department = Request::input('department', []); + $user = User::createByAdmin($email, $password, $nickname, [ + 'changePass' => $changePass, + 'profession' => $profession, + 'department' => is_array($department) ? $department : [], + ]); + return Base::retSuccess('创建成功', $user); + } + + /** + * @api {post} api/users/import/preview 批量导入预览(管理员) + * + * @apiDescription 需要token身份(管理员)。上传 Excel/CSV(列顺序:邮箱、昵称、初始密码、职位(选填)),仅解析+校验、不创建账号 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName import__preview + */ + public function import__preview() + { + User::auth('admin'); + $file = Request::file('file'); + if (empty($file)) { + return Base::retError('请选择文件'); + } + $ext = strtolower($file->getClientOriginalExtension()); + if (!in_array($ext, ['xls', 'xlsx', 'csv'])) { + return Base::retError('仅支持 xls/xlsx/csv 文件'); + } + $sheets = Excel::toArray(new UserImport, $file); + $sheet = $sheets[0] ?? []; + $rows = User::parseImportRows($sheet); + if (empty($rows)) { + return Base::retError('文件中没有可导入的数据'); + } + return Base::retSuccess('解析完成', User::importPreview($rows)); + } + + /** + * @api {post} api/users/import 批量导入用户(管理员) + * + * @apiDescription 需要token身份(管理员)。提交预览确认后的行数据 rows(每行 {email,nickname,password,profession},可选 department[])进行创建 + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName import + */ + public function import() + { + User::auth('admin'); + $rows = Request::input('rows'); + if (!is_array($rows) || empty($rows)) { + return Base::retError('没有可导入的数据'); + } + $changePass = intval(Request::input('changepass', 1)) === 1; + $result = User::importUsers($rows, $changePass); + return Base::retSuccess('导入完成', $result); + } + + /** + * @api {get} api/users/import/template 下载批量导入模板(管理员) + * + * @apiVersion 1.0.0 + * @apiGroup users + * @apiName import__template + */ + public function import__template() + { + User::auth('admin'); + return Excel::download(new UserImportTemplate, 'user_import_template.xlsx'); + } + /** * @api {get} api/users/email/verification 邮箱验证 * diff --git a/app/Models/User.php b/app/Models/User.php index 05435e7dc..e1c79840a 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -89,6 +89,8 @@ use Carbon\Carbon; */ class User extends AbstractModel { + const IMPORT_MAX = 500; + protected $primaryKey = 'userid'; protected $hidden = [ @@ -425,6 +427,283 @@ class User extends AbstractModel return $createdUser; } + /** + * 管理员创建员工账号(复用注册逻辑,强制正式身份,可选首登改密 / 部门 / 职位) + * @param string $email + * @param string $password + * @param string $nickname + * @param array $options changePass(bool,默认true) / department(int[]) / profession(string) + * @return self + * @throws ApiException + */ + public static function createByAdmin(string $email, $password, string $nickname, array $options = []): self + { + $nickname = trim($nickname); + if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) { + throw new ApiException('昵称需为2-20个字'); + } + $changePass = ($options['changePass'] ?? true) ? 1 : 0; + $profession = trim((string)($options['profession'] ?? '')); + // 校验前置(reg 之前快速失败,且可在无 Swoole 环境单测) + self::assertValidProfession($profession); + $departmentIds = self::assertValidDepartments($options['department'] ?? []); + // 复用 reg:邮箱校验/查重、passwordPolicy、Doo::userCreate、az/pinyin、全员群、索引同步、user_onboard hook + $user = self::reg($email, $password, ['nickname' => $nickname]); + // 管理员显式创建的账号视为正式员工,去除系统 reg_identity 可能带上的 temp + if (in_array('temp', $user->identity)) { + $user->identity = Base::arrayImplode(array_diff($user->identity, ['temp'])); + } + $user->changepass = $changePass; // 复用现有首登强制改密机制 + if ($profession !== '') { + $user->profession = $profession; + } + if ($departmentIds) { + $user->department = Base::arrayImplode($departmentIds); + } + $user->save(); + // 设置了部门 → 加入对应部门群(复刻 operation 的 type=department 入群逻辑) + if ($departmentIds) { + $departments = UserDepartment::whereIn('id', $departmentIds)->get(); + foreach ($departments as $department) { + try { + if ($department->dialog_id > 0 && $dialog = WebSocketDialog::find($department->dialog_id)) { + $dialog->joinGroup([$user->userid], 0, true); + $dialog->pushMsg("groupJoin", null, [$user->userid]); + } + } catch (\Throwable $e) { + // 部门入群为尽力投递:单个部门失败不影响账号创建与其他部门 + \Log::warning('createByAdmin: 部门入群失败', [ + 'userid' => $user->userid, + 'department_id' => $department->id, + 'error' => $e->getMessage(), + ]); + } + } + } + return $user; + } + + /** + * 将上传表格(Excel::toArray 的二维数组)归一化为导入行 + * @param array $sheet + * @return array [{line, email, nickname, password}] + */ + public static function parseImportRows(array $sheet): array + { + $rows = []; + foreach ($sheet as $index => $cells) { + if ($index === 0) { + continue; // 表头 + } + $email = trim((string)($cells[0] ?? '')); + $nickname = trim((string)($cells[1] ?? '')); + $password = trim((string)($cells[2] ?? '')); + $profession = trim((string)($cells[3] ?? '')); + if ($email === '' && $nickname === '' && $password === '') { + continue; // 空行(仅职位有值也视为空行跳过) + } + $rows[] = [ + 'line' => $index + 1, // 电子表格行号(从 1 开始) + 'email' => $email, + 'nickname' => $nickname, + 'password' => $password, + 'profession' => $profession, + ]; + } + return $rows; + } + + /** + * 校验单条导入行 + * @param array $row ['email'=>,'nickname'=>,'password'=>,'profession'=>(选填)] + * @return string|null 错误文案;null 表示通过 + */ + public static function validateImportRow(array $row): ?string + { + $email = trim((string)($row['email'] ?? '')); + $nickname = trim((string)($row['nickname'] ?? '')); + $password = trim((string)($row['password'] ?? '')); + if ($email === '' || $nickname === '' || $password === '') { + return '邮箱、昵称、初始密码均为必填'; + } + if (!Base::isEmail($email)) { + return '邮箱格式不正确'; + } + if (mb_strlen($nickname) < 2 || mb_strlen($nickname) > 20) { + return '昵称需为2-20个字'; + } + try { + self::passwordPolicy($password); + } catch (ApiException $e) { + return $e->getMessage(); + } + // 职位/职称选填,填写则校验 2-20 字 + try { + self::assertValidProfession((string)($row['profession'] ?? '')); + } catch (ApiException $e) { + return $e->getMessage(); + } + return null; + } + + /** + * 校验职位/职称:非空时必须 2-20 字(复用 operation 的现有文案) + * @param string $profession + * @return void + * @throws ApiException + */ + public static function assertValidProfession(string $profession): void + { + $profession = trim($profession); + if ($profession === '') { + return; + } + if (mb_strlen($profession) < 2) { + throw new ApiException('职位/职称不可以少于2个字'); + } + if (mb_strlen($profession) > 20) { + throw new ApiException('职位/职称最多只能设置20个字'); + } + } + + /** + * 规整并校验部门 ID 列表:转正整数去重、最多 10 个、且每个必须存在 + * @param mixed $ids + * @return int[] + * @throws ApiException + */ + public static function assertValidDepartments($ids): array + { + if (!is_array($ids)) { + $ids = []; + } + $ids = array_values(array_unique(array_filter(array_map('intval', $ids), fn($v) => $v > 0))); + if (count($ids) > 10) { + throw new ApiException('最多只可加入10个部门'); + } + if ($ids) { + $existing = UserDepartment::whereIn('id', $ids)->pluck('id')->map(fn($v) => (int)$v)->all(); + if (count($existing) < count($ids)) { + throw new ApiException('修改部门不存在'); + } + } + return $ids; + } + + /** + * 批量导入用户(部门/职位逐行:department 来自前端逐行设置,profession 来自 Excel 行) + * @param array $rows 每行含 email/nickname/password/profession,可选 department(int[]) + * @param bool $changePass 是否要求首登改密(对本批所有账号生效) + * @return array ['total'=>int, 'success'=>int, 'failed'=>[['line','email','reason']]] + * @throws ApiException 行数超限 + */ + public static function importUsers(array $rows, bool $changePass = true): array + { + if (count($rows) > self::IMPORT_MAX) { + throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条'); + } + $success = 0; + $failed = []; + $seen = []; + foreach ($rows as $row) { + $error = self::validateImportRow($row); + if ($error === null) { + $emailLower = strtolower(trim((string)$row['email'])); + if (isset($seen[$emailLower])) { + $error = '文件内邮箱重复'; + } else { + $seen[$emailLower] = true; + } + } + if ($error === null) { + try { + self::createByAdmin($row['email'], $row['password'], $row['nickname'], [ + 'changePass' => $changePass, + 'department' => $row['department'] ?? [], + 'profession' => $row['profession'] ?? '', + ]); + $success++; + continue; + } catch (ApiException $e) { + $error = $e->getMessage(); + } + } + $failed[] = [ + 'line' => $row['line'] ?? 0, + 'email' => $row['email'] ?? '', + 'reason' => $error, + ]; + } + return [ + 'total' => count($rows), + 'success' => $success, + 'failed' => $failed, + ]; + } + + /** + * 批量导入预览(只解析+校验,不创建任何账号) + * 逐行判定 ok/error:必填/邮箱格式/昵称长度/密码策略、文件内邮箱重复、系统中邮箱已存在 + * @param array $rows parseImportRows 的输出 + * @return array ['total'=>int,'valid'=>int,'invalid'=>int,'rows'=>[['line','email','nickname','password','status','reason']]] + */ + public static function importPreview(array $rows): array + { + if (count($rows) > self::IMPORT_MAX) { + throw new ApiException('单次最多导入' . self::IMPORT_MAX . '条'); + } + // 预查系统中已存在的邮箱(小写比较) + $emails = []; + foreach ($rows as $row) { + $e = strtolower(trim((string)($row['email'] ?? ''))); + if ($e !== '') { + $emails[$e] = true; + } + } + $existing = []; + if ($emails) { + foreach (self::whereIn('email', array_keys($emails))->pluck('email') as $em) { + $existing[strtolower($em)] = true; + } + } + $seen = []; + $valid = 0; + $list = []; + foreach ($rows as $row) { + $reason = self::validateImportRow($row); + $emailLower = strtolower(trim((string)($row['email'] ?? ''))); + if ($reason === null) { + if (isset($seen[$emailLower])) { + $reason = '文件内邮箱重复'; + } else { + $seen[$emailLower] = true; + if (isset($existing[$emailLower])) { + $reason = '邮箱地址已存在'; + } + } + } + $ok = $reason === null; + if ($ok) { + $valid++; + } + $list[] = [ + 'line' => $row['line'] ?? 0, + 'email' => $row['email'] ?? '', + 'nickname' => $row['nickname'] ?? '', + 'password' => $row['password'] ?? '', + 'profession' => $row['profession'] ?? '', + 'status' => $ok ? 'ok' : 'error', + 'reason' => $reason ?? '', + ]; + } + return [ + 'total' => count($rows), + 'valid' => $valid, + 'invalid' => count($rows) - $valid, + 'rows' => $list, + ]; + } + /** * 获取我的ID * @return int diff --git a/app/Module/UserImport.php b/app/Module/UserImport.php new file mode 100644 index 000000000..24cea7739 --- /dev/null +++ b/app/Module/UserImport.php @@ -0,0 +1,13 @@ + + +
+ + + + + + + + + + + + + + + + + {{$L('员工首次登录需修改密码')}} + +
+
+ + +
+
+ + + diff --git a/resources/assets/js/pages/manage/components/ImportUserModal.vue b/resources/assets/js/pages/manage/components/ImportUserModal.vue new file mode 100644 index 000000000..be8288114 --- /dev/null +++ b/resources/assets/js/pages/manage/components/ImportUserModal.vue @@ -0,0 +1,365 @@ + + + + + diff --git a/resources/assets/js/pages/manage/components/TeamManagement.vue b/resources/assets/js/pages/manage/components/TeamManagement.vue index 3282af2a4..856864377 100644 --- a/resources/assets/js/pages/manage/components/TeamManagement.vue +++ b/resources/assets/js/pages/manage/components/TeamManagement.vue @@ -169,6 +169,10 @@ @refresh="getLists" @cancelFilter="keyIs=false"/> +
  • + + +
  • @@ -269,6 +273,8 @@ :checkin-mode="checkinMode" :department-list="departmentList" @updated="getLists"/> + + + + {{ $L('员工下次登录需修改密码') }} + @@ -177,6 +180,7 @@ export default { profession: '', email: '', password: '', + changepass: true, department: [], introduction: '', faceimg: [], @@ -249,6 +253,7 @@ export default { profession: profession || '', email: email || '', password: '', + changepass: true, department: Array.isArray(department) ? department.map(id => parseInt(id)) : [], @@ -377,6 +382,7 @@ export default { } if (this.formData.password) { data.password = this.formData.password; + data.changepass = this.formData.changepass ? 1 : 0; } this.$store.dispatch("call", { url: 'users/operation', diff --git a/tests/Feature/AdminCreateUserTest.php b/tests/Feature/AdminCreateUserTest.php new file mode 100644 index 000000000..943c39cc9 --- /dev/null +++ b/tests/Feature/AdminCreateUserTest.php @@ -0,0 +1,187 @@ +getMessage(); + return str_contains($msg, 'swoole') + || str_contains($msg, 'Swoole') + || str_contains($msg, 'AbstractData::__wakeup') + || str_contains($msg, 'Undefined array key'); + } + + public function test_create_by_admin_sets_changepass_and_normal_identity() + { + // Doo::userCreate 内部会调用 DB::commit() 绕过 DatabaseTransactions 的事务回滚。 + // 为保证幂等,先提交当前事务(让 DELETE 对 SO 的独立 DB 连接可见), + // 删除残留用户后再重新开启事务,使后续测试代码仍在事务保护下运行。 + \DB::commit(); + \DB::table('users')->where('email', 'newstaff@test.local')->delete(); + \DB::beginTransaction(); + + try { + $user = User::createByAdmin('newstaff@test.local', 'Abc123456', '新员工'); + } catch (\Throwable $e) { + if ($this->isSwooleInfraFailure($e)) { + $this->markTestSkipped('Swoole 运行时不可用,createByAdmin 端到端无法验证:' . $e->getMessage()); + } + throw $e; + } + + $this->assertSame('newstaff@test.local', $user->email); + $this->assertSame('新员工', $user->nickname); + $this->assertSame(1, (int)$user->changepass, '首登应强制改密'); + $this->assertNotContains('temp', $user->identity, '管理员创建账号应为正式身份'); + $this->assertSame($user->password, \App\Module\Doo::md5s('Abc123456', $user->encrypt)); + + // 手动清理:Doo::userCreate 的 DB::commit 已将创建的用户持久化,DatabaseTransactions 无法回滚 + \DB::table('users')->where('email', 'newstaff@test.local')->delete(); + } + + public function test_create_by_admin_can_skip_changepass() + { + // 同上:先提交事务让 DELETE 对 SO 独立连接可见,保证幂等 + \DB::commit(); + \DB::table('users')->where('email', 'nochg@test.local')->delete(); + \DB::beginTransaction(); + + try { + $user = User::createByAdmin('nochg@test.local', 'Abc123456', '免改密', ['changePass' => false]); + } catch (\Throwable $e) { + if ($this->isSwooleInfraFailure($e)) { + $this->markTestSkipped('Swoole 运行时不可用,createByAdmin 端到端无法验证:' . $e->getMessage()); + } + throw $e; + } + + $this->assertSame(0, (int)$user->changepass, 'changePass=false 时不应要求首登改密'); + + \DB::table('users')->where('email', 'nochg@test.local')->delete(); + } + + public function test_create_by_admin_rejects_bad_nickname() + { + $this->expectException(\App\Exceptions\ApiException::class); + User::createByAdmin('x@test.local', 'Abc123456', '王'); // 昵称不足 2 字,校验在 SO 之前 + } + + public function test_create_by_admin_rejects_bad_profession() + { + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('职位/职称不可以少于2个字'); + User::createByAdmin('p@test.local', 'Abc123456', '张三', ['profession' => 'A']); + } + + public function test_create_by_admin_rejects_too_many_departments() + { + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('最多只可加入10个部门'); + User::createByAdmin('d@test.local', 'Abc123456', '张三', ['department' => range(1, 11)]); + } + + public function test_create_by_admin_rejects_nonexistent_department() + { + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('修改部门不存在'); + User::createByAdmin('d2@test.local', 'Abc123456', '张三', ['department' => [999999]]); + } + + public function test_import_rejects_over_limit() + { + $rows = []; + for ($i = 0; $i <= User::IMPORT_MAX; $i++) { // 501 行 + $rows[] = ['line' => $i + 2, 'email' => "u{$i}@test.local", 'nickname' => "员工{$i}", 'password' => 'Abc123456']; + } + $this->expectException(\App\Exceptions\ApiException::class); + User::importUsers($rows); + } + + public function test_import_preview_marks_existing_dup_and_invalid() + { + // 造一个已存在用户(直接 createInstance,不走 SO) + $existing = User::createInstance([ + 'email' => 'exists@test.local', + 'nickname' => '老员工', + 'userimg' => '', + 'profession' => '', + 'password' => md5('x'), + ]); + $existing->save(); + + $rows = [ + ['line' => 2, 'email' => 'exists@test.local', 'nickname' => '张三', 'password' => 'Abc123456'], // 系统已存在 + ['line' => 3, 'email' => 'newp1@test.local', 'nickname' => '李四', 'password' => 'Abc123456'], // ok + ['line' => 4, 'email' => 'newp1@test.local', 'nickname' => '王五', 'password' => 'Abc123456'], // 文件内重复 + ['line' => 5, 'email' => 'bad', 'nickname' => '赵六', 'password' => 'Abc123456'], // 邮箱格式 + ]; + + $preview = User::importPreview($rows); + + $this->assertSame(4, $preview['total']); + $this->assertSame(1, $preview['valid']); + $this->assertSame(3, $preview['invalid']); + $this->assertSame('error', $preview['rows'][0]['status']); + $this->assertSame('邮箱地址已存在', $preview['rows'][0]['reason']); + $this->assertSame('ok', $preview['rows'][1]['status']); + $this->assertSame('error', $preview['rows'][2]['status']); + $this->assertSame('文件内邮箱重复', $preview['rows'][2]['reason']); + $this->assertSame('error', $preview['rows'][3]['status']); + $this->assertSame('邮箱格式不正确', $preview['rows'][3]['reason']); + // 预览不创建账号 + $this->assertSame(0, User::whereEmail('newp1@test.local')->count()); + } + + public function test_import_collects_all_invalid_rows_without_creating() + { + // 全部非法 → 不触发 createByAdmin/SO,可在无 Swoole 环境稳定运行 + $rows = [ + ['line' => 2, 'email' => '', 'nickname' => '张三', 'password' => 'Abc123456'], + ['line' => 3, 'email' => 'bad-email', 'nickname' => '李四', 'password' => 'Abc123456'], + ['line' => 4, 'email' => 'c@test.local', 'nickname' => '王', 'password' => 'Abc123456'], + ]; + + $result = User::importUsers($rows); + + $this->assertSame(3, $result['total']); + $this->assertSame(0, $result['success']); + $this->assertCount(3, $result['failed']); + $this->assertSame(2, $result['failed'][0]['line']); + $this->assertSame('邮箱、昵称、初始密码均为必填', $result['failed'][0]['reason']); + $this->assertSame('邮箱格式不正确', $result['failed'][1]['reason']); + $this->assertSame('昵称需为2-20个字', $result['failed'][2]['reason']); + } + + public function test_import_marks_row_with_bad_profession() + { + // 行内职位非法(1字)→ 该行被标记失败,不创建账号 + $rows = [ + ['line' => 2, 'email' => 'badprof@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => 'A'], + ]; + $result = User::importUsers($rows, true); + $this->assertSame(0, $result['success']); + $this->assertCount(1, $result['failed']); + $this->assertSame('职位/职称不可以少于2个字', $result['failed'][0]['reason']); + } + + public function test_import_marks_row_with_nonexistent_department() + { + // 行内部门不存在 → createByAdmin 在 reg() 之前抛异常 → 该行失败(无需 Swoole) + $rows = [ + ['line' => 2, 'email' => 'baddept@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => '', 'department' => [999999]], + ]; + $result = User::importUsers($rows, true); + $this->assertSame(0, $result['success']); + $this->assertCount(1, $result['failed']); + $this->assertSame('修改部门不存在', $result['failed'][0]['reason']); + } +} diff --git a/tests/Unit/UserImportParseTest.php b/tests/Unit/UserImportParseTest.php new file mode 100644 index 000000000..194af2d4b --- /dev/null +++ b/tests/Unit/UserImportParseTest.php @@ -0,0 +1,137 @@ +assertCount(2, $rows); + $this->assertSame('a@test.local', $rows[0]['email']); + $this->assertSame('张三', $rows[0]['nickname']); + $this->assertSame('Abc123456', $rows[0]['password']); + $this->assertSame(2, $rows[0]['line']); + $this->assertSame(4, $rows[1]['line']); + } + + public function test_parse_trims_cells() + { + $sheet = [ + ['邮箱', '昵称', '初始密码'], + [' a@test.local ', ' 张三 ', ' Abc123456 '], + ]; + + $rows = User::parseImportRows($sheet); + + $this->assertSame('a@test.local', $rows[0]['email']); + $this->assertSame('张三', $rows[0]['nickname']); + $this->assertSame('Abc123456', $rows[0]['password']); + } + + public function test_validate_passes_for_valid_row() + { + $row = ['email' => 'ok@test.local', 'nickname' => '张三', 'password' => 'Abc123456']; + $this->assertNull(User::validateImportRow($row)); + } + + public function test_validate_requires_all_fields() + { + $this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => '', 'nickname' => '张三', 'password' => 'Abc123456'])); + $this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '', 'password' => 'Abc123456'])); + $this->assertSame('邮箱、昵称、初始密码均为必填', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '张三', 'password' => ''])); + } + + public function test_validate_rejects_bad_email() + { + $this->assertSame('邮箱格式不正确', User::validateImportRow(['email' => 'not-an-email', 'nickname' => '张三', 'password' => 'Abc123456'])); + } + + public function test_validate_rejects_bad_nickname_length() + { + $this->assertSame('昵称需为2-20个字', User::validateImportRow(['email' => 'a@test.local', 'nickname' => '王', 'password' => 'Abc123456'])); + $this->assertSame('昵称需为2-20个字', User::validateImportRow(['email' => 'a@test.local', 'nickname' => str_repeat('字', 21), 'password' => 'Abc123456'])); + } + + public function test_validate_rejects_short_password() + { + $this->assertNotNull(User::validateImportRow(['email' => 'a@test.local', 'nickname' => '张三', 'password' => '123'])); + } + + public function test_assert_valid_profession_passes_for_empty_and_normal() + { + // 空职位允许(可选字段),2/20 字边界与正常值允许;不抛异常即通过 + User::assertValidProfession(''); + User::assertValidProfession('工程'); // 恰好 2 字 + User::assertValidProfession('工程师'); + User::assertValidProfession(str_repeat('字', 20)); // 恰好 20 字 + $this->assertTrue(true); + } + + public function test_assert_valid_profession_rejects_too_short() + { + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('职位/职称不可以少于2个字'); + User::assertValidProfession('A'); + } + + public function test_assert_valid_profession_rejects_too_long() + { + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('职位/职称最多只能设置20个字'); + User::assertValidProfession(str_repeat('字', 21)); + } + + public function test_assert_valid_departments_normalizes_ids() + { + // 空/非数组 → 返回空数组 + $this->assertSame([], User::assertValidDepartments([])); + $this->assertSame([], User::assertValidDepartments('not-array')); + // 去重 + 转 int + 过滤非正数(这些路径不查库) + $this->assertSame([3, 5], User::assertValidDepartments(['3', 3, 5, 0, -1])); + } + + public function test_assert_valid_departments_rejects_over_limit() + { + // 超过 10 个(count 校验在查库之前)→ 抛异常 + $this->expectException(\App\Exceptions\ApiException::class); + $this->expectExceptionMessage('最多只可加入10个部门'); + User::assertValidDepartments(range(1, 11)); + } + + public function test_parse_reads_profession_column() + { + $sheet = [ + ['邮箱', '昵称', '初始密码', '职位'], + ['a@test.local', '张三', 'Abc123456', '工程师'], + ['b@test.local', '李四', 'Xyz123456'], // 无职位列 → profession 为空 + ]; + $rows = User::parseImportRows($sheet); + $this->assertCount(2, $rows); + $this->assertSame('工程师', $rows[0]['profession']); + $this->assertSame('', $rows[1]['profession']); + } + + public function test_validate_passes_for_empty_profession() + { + $row = ['email' => 'a@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => '']; + $this->assertNull(User::validateImportRow($row)); + } + + public function test_validate_rejects_bad_profession() + { + $row = ['email' => 'a@test.local', 'nickname' => '张三', 'password' => 'Abc123456', 'profession' => 'A']; + $this->assertSame('职位/职称不可以少于2个字', User::validateImportRow($row)); + } +}