feat(manage): 团队管理支持管理员创建/批量导入员工账号(含部门、职位)

单个创建:邮箱/昵称/初始密码 + 可选首登改密、职位、部门(多选,选子部门自动补选上级,并加入对应部门群)。

批量导入:上传 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 <noreply@anthropic.com>
This commit is contained in:
kuaifan
2026-06-01 01:26:34 +00:00
co-authored by Claude Opus 4.8
parent aa2e0acaba
commit 20b5daba50
12 changed files with 1297 additions and 2 deletions
+187
View File
@@ -0,0 +1,187 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\DatabaseTransactions;
use Tests\TestCase;
class AdminCreateUserTest extends TestCase
{
use DatabaseTransactions;
/** Swoole 运行时缺失/Task 不可用属环境性失败,与业务无关 */
private function isSwooleInfraFailure(\Throwable $e): bool
{
$msg = $e->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']);
}
}