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 @@
+
+
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+ {{$L('请按模板填写后上传,列顺序:邮箱、昵称、初始密码、职位(选填);单次最多导入500条。')}}
+
+
+
+
+
+
+
+
+
+
+ {{$L('共(*)条 · 可导入(*)条 · 错误(*)条', preview.total, preview.valid, preview.invalid)}}
+
+
+
+
+
+
+
+
+ {{$L('员工首次登录需修改密码')}}
+
+
+
+
+
+
+ {{$L('导入结果:共(*)条,成功(*)条,失败(*)条', result.total, result.success, result.failed.length)}}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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));
+ }
+}