feat(工单): 完成工单系统的编写
This commit is contained in:
parent
47d4749afb
commit
b02ff30ef2
@ -134,6 +134,9 @@ class UserManageCommand extends Command
|
||||
'platform.oauth_clients.manage',
|
||||
'platform.oauth_scopes.view',
|
||||
'platform.oauth_scopes.manage',
|
||||
'platform.tickets.view',
|
||||
'platform.tickets.manage',
|
||||
'tickets.use',
|
||||
'resource.servers.use',
|
||||
];
|
||||
|
||||
@ -156,6 +159,7 @@ class UserManageCommand extends Command
|
||||
|
||||
$adminRole->syncPermissions($permissions);
|
||||
$userRole->syncPermissions([
|
||||
'tickets.use',
|
||||
'resource.servers.use',
|
||||
]);
|
||||
|
||||
|
||||
@ -9,6 +9,7 @@ use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Illuminate\Validation\Rules\Password;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Spatie\Permission\Models\Role;
|
||||
|
||||
#[Apidoc\Title('认证模块')]
|
||||
@ -103,10 +104,56 @@ class AuthController extends Controller
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'nickname' => ['required', 'string', 'max:255'],
|
||||
'email' => ['required', 'email', 'max:255', 'unique:users,email'],
|
||||
'phone' => ['required', 'string', 'regex:/^1[3-9]\d{9}$/', 'unique:users,phone'],
|
||||
'email' => ['required', 'email', 'max:255'],
|
||||
'phone' => ['required', 'string', 'regex:/^1[3-9]\d{9}$/'],
|
||||
'password' => ['required', 'confirmed', Password::min(6)],
|
||||
'application_note' => ['nullable', 'string', 'max:2000'],
|
||||
]);
|
||||
$guestRole = Role::query()->firstOrCreate(
|
||||
['name' => 'guest', 'guard_name' => 'api'],
|
||||
['name' => 'guest', 'guard_name' => 'api']
|
||||
);
|
||||
|
||||
$existingUsers = User::query()
|
||||
->with('roles')
|
||||
->where('email', $validated['email'])
|
||||
->orWhere('phone', $validated['phone'])
|
||||
->get();
|
||||
|
||||
if ($existingUsers->isNotEmpty()) {
|
||||
if ($existingUsers->contains(fn (User $user): bool => ! $user->hasRole('guest', 'api'))) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => ['邮箱或手机号对应的账号已开通,不可重复提交申请。'],
|
||||
]);
|
||||
}
|
||||
|
||||
if ($existingUsers->count() > 1) {
|
||||
throw ValidationException::withMessages([
|
||||
'account' => ['邮箱和手机号分别对应不同申请账号,请联系管理员处理。'],
|
||||
]);
|
||||
}
|
||||
|
||||
/** @var User $existingUser */
|
||||
$existingUser = $existingUsers->first();
|
||||
$existingUser->fill([
|
||||
'nickname' => $validated['nickname'],
|
||||
'email' => $validated['email'],
|
||||
'phone' => $validated['phone'],
|
||||
'password' => $validated['password'],
|
||||
'force_password_change' => false,
|
||||
'application_note' => $validated['application_note'] ?? null,
|
||||
]);
|
||||
$existingUser->save();
|
||||
$existingUser->syncRoles([$guestRole->id]);
|
||||
|
||||
$this->auditLog($request, 'account_apply_update', ['metadata' => ['target_user_id' => $existingUser->id]]);
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => '申请信息已更新,请联系或等待管理员开通权限后登录控制台',
|
||||
'data' => ['updated' => true],
|
||||
]);
|
||||
}
|
||||
|
||||
$user = User::query()->create([
|
||||
'nickname' => $validated['nickname'],
|
||||
@ -114,12 +161,8 @@ class AuthController extends Controller
|
||||
'phone' => $validated['phone'],
|
||||
'password' => $validated['password'],
|
||||
'force_password_change' => false,
|
||||
'application_note' => $validated['application_note'] ?? null,
|
||||
]);
|
||||
|
||||
$guestRole = Role::query()->firstOrCreate(
|
||||
['name' => 'guest', 'guard_name' => 'api'],
|
||||
['name' => 'guest', 'guard_name' => 'api']
|
||||
);
|
||||
$user->syncRoles([$guestRole->id]);
|
||||
|
||||
$this->auditLog($request, 'account_apply', ['metadata' => ['target_user_id' => $user->id]]);
|
||||
@ -127,7 +170,7 @@ class AuthController extends Controller
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => '申请提交成功,请联系或等待管理员开通权限后登录控制台',
|
||||
'data' => null,
|
||||
'data' => ['updated' => false],
|
||||
], 201);
|
||||
}
|
||||
|
||||
|
||||
@ -143,6 +143,9 @@ class PermissionController extends Controller
|
||||
'platform.oauth_clients.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth 客户端配置与密钥'],
|
||||
'platform.oauth_scopes.view' => ['category' => 'OAuth 管理', 'description' => '查看 OAuth Scope 配置'],
|
||||
'platform.oauth_scopes.manage' => ['category' => 'OAuth 管理', 'description' => '维护 OAuth Scope 配置'],
|
||||
'platform.tickets.view' => ['category' => '工单管理', 'description' => '查看全部工单'],
|
||||
'platform.tickets.manage' => ['category' => '工单管理', 'description' => '处理工单与维护工单分类'],
|
||||
'tickets.use' => ['category' => '工单', 'description' => '提交工单并查看自己的工单反馈'],
|
||||
'resource.servers.use' => ['category' => '资源使用', 'description' => '发起服务器资源访问与连接操作'],
|
||||
];
|
||||
|
||||
|
||||
180
app/Http/Controllers/Api/TicketCategoryController.php
Normal file
180
app/Http/Controllers/Api/TicketCategoryController.php
Normal file
@ -0,0 +1,180 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\TicketCategory;
|
||||
use hg\apidoc\annotation as Apidoc;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
#[Apidoc\Title('工单分类管理')]
|
||||
class TicketCategoryController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:api');
|
||||
$this->middleware('permission:tickets.use|platform.tickets.view|platform.tickets.manage,api')->only(['index']);
|
||||
$this->middleware('permission:platform.tickets.manage,api')->only(['store', 'update', 'destroy']);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('工单分类列表'), Apidoc\Method('GET'), Apidoc\Url('/ticket-categories')]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'active_only' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
$query = TicketCategory::query()
|
||||
->with('parent:id,name,parent_id')
|
||||
->orderBy('parent_id')
|
||||
->orderBy('id');
|
||||
if ((bool) ($validated['active_only'] ?? false)) {
|
||||
$query->where('is_active', true);
|
||||
}
|
||||
|
||||
$categories = $query->get();
|
||||
|
||||
return response()->json([
|
||||
'code' => 0,
|
||||
'message' => 'ok',
|
||||
'data' => $categories,
|
||||
'tree' => $this->buildCategoryTree($categories),
|
||||
]);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('创建工单分类'), Apidoc\Method('POST'), Apidoc\Url('/ticket-categories')]
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'parent_id' => ['nullable', 'integer', 'exists:ticket_categories,id'],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
$this->ensureUniqueNameInParent($validated['name'], $validated['parent_id'] ?? null);
|
||||
|
||||
$category = TicketCategory::query()->create([
|
||||
'parent_id' => $validated['parent_id'] ?? null,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => (bool) ($validated['is_active'] ?? true),
|
||||
]);
|
||||
|
||||
$this->auditLog($request, 'ticket_category_create', ['metadata' => ['ticket_category_id' => $category->id]]);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $category], 201);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('更新工单分类'), Apidoc\Method('PUT'), Apidoc\Url('/ticket-categories/{id}')]
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$category = TicketCategory::query()->findOrFail($id);
|
||||
$validated = $request->validate([
|
||||
'parent_id' => ['nullable', 'integer', Rule::exists('ticket_categories', 'id')->where(fn ($query) => $query->where('id', '!=', $category->id))],
|
||||
'name' => ['required', 'string', 'max:100'],
|
||||
'description' => ['nullable', 'string', 'max:255'],
|
||||
'is_active' => ['sometimes', 'boolean'],
|
||||
]);
|
||||
$parentId = $validated['parent_id'] ?? null;
|
||||
if ($parentId !== null && $this->isDescendant((int) $parentId, $category)) {
|
||||
throw ValidationException::withMessages([
|
||||
'parent_id' => ['不能选择当前分类的下级分类作为父级。'],
|
||||
]);
|
||||
}
|
||||
$this->ensureUniqueNameInParent($validated['name'], $parentId, $category->id);
|
||||
|
||||
$category->update([
|
||||
'parent_id' => $parentId,
|
||||
'name' => $validated['name'],
|
||||
'description' => $validated['description'] ?? null,
|
||||
'is_active' => array_key_exists('is_active', $validated) ? (bool) $validated['is_active'] : $category->is_active,
|
||||
]);
|
||||
|
||||
$this->auditLog($request, 'ticket_category_update', ['metadata' => ['ticket_category_id' => $category->id]]);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $category->fresh()]);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('删除工单分类'), Apidoc\Method('DELETE'), Apidoc\Url('/ticket-categories/{id}')]
|
||||
public function destroy(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$category = TicketCategory::query()->findOrFail($id);
|
||||
$this->auditLog($request, 'ticket_category_delete', ['metadata' => ['ticket_category_id' => $category->id]]);
|
||||
$category->delete();
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => null]);
|
||||
}
|
||||
|
||||
private function buildCategoryTree($categories): array
|
||||
{
|
||||
$items = $categories
|
||||
->map(function (TicketCategory $category): array {
|
||||
return [
|
||||
'id' => $category->id,
|
||||
'parent_id' => $category->parent_id,
|
||||
'name' => $category->name,
|
||||
'description' => $category->description,
|
||||
'is_active' => $category->is_active,
|
||||
'children' => [],
|
||||
];
|
||||
})
|
||||
->keyBy('id')
|
||||
->all();
|
||||
|
||||
$tree = [];
|
||||
foreach ($items as $id => &$item) {
|
||||
$parentId = $item['parent_id'];
|
||||
if ($parentId !== null && isset($items[$parentId])) {
|
||||
$items[$parentId]['children'][] = &$item;
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
$tree[] = &$item;
|
||||
}
|
||||
unset($item);
|
||||
|
||||
return $tree;
|
||||
}
|
||||
|
||||
private function isDescendant(int $parentId, TicketCategory $category): bool
|
||||
{
|
||||
$childrenByParent = TicketCategory::query()
|
||||
->whereNotNull('parent_id')
|
||||
->get(['id', 'parent_id'])
|
||||
->groupBy('parent_id');
|
||||
$pending = collect($childrenByParent->get($category->id, []))->pluck('id')->all();
|
||||
|
||||
while (! empty($pending)) {
|
||||
$nextId = (int) array_shift($pending);
|
||||
if ($nextId === $parentId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$pending = [
|
||||
...$pending,
|
||||
...collect($childrenByParent->get($nextId, []))->pluck('id')->all(),
|
||||
];
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function ensureUniqueNameInParent(string $name, ?int $parentId, ?int $exceptId = null): void
|
||||
{
|
||||
$exists = TicketCategory::query()
|
||||
->where('name', $name)
|
||||
->when($parentId === null, fn ($query) => $query->whereNull('parent_id'), fn ($query) => $query->where('parent_id', $parentId))
|
||||
->when($exceptId !== null, fn ($query) => $query->where('id', '!=', $exceptId))
|
||||
->exists();
|
||||
|
||||
if ($exists) {
|
||||
throw ValidationException::withMessages([
|
||||
'name' => ['同级分类下已存在相同名称。'],
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
189
app/Http/Controllers/Api/TicketController.php
Normal file
189
app/Http/Controllers/Api/TicketController.php
Normal file
@ -0,0 +1,189 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketCategory;
|
||||
use App\Models\TicketMessage;
|
||||
use App\Models\User;
|
||||
use hg\apidoc\annotation as Apidoc;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
#[Apidoc\Title('工单管理')]
|
||||
class TicketController extends Controller
|
||||
{
|
||||
public function __construct()
|
||||
{
|
||||
$this->middleware('auth:api');
|
||||
$this->middleware('permission:tickets.use|platform.tickets.view|platform.tickets.manage,api')->only(['index', 'show']);
|
||||
$this->middleware('permission:tickets.use,api')->only(['store']);
|
||||
$this->middleware('permission:tickets.use|platform.tickets.manage,api')->only(['reply']);
|
||||
$this->middleware('permission:platform.tickets.manage,api')->only(['update']);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('工单列表'), Apidoc\Method('GET'), Apidoc\Url('/tickets')]
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'per_page' => ['nullable', 'integer', 'min:1', 'max:100'],
|
||||
'status' => ['nullable', 'string', Rule::in(Ticket::STATUSES)],
|
||||
'category_id' => ['nullable', 'integer', 'exists:ticket_categories,id'],
|
||||
'mine' => ['nullable', 'boolean'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth('api')->user();
|
||||
$canViewAll = $this->canViewAllTickets($user);
|
||||
$query = Ticket::query()
|
||||
->with(['user:id,nickname,email,phone', 'category', 'assignedUser:id,nickname,email'])
|
||||
->withCount('messages')
|
||||
->latest('last_replied_at')
|
||||
->latest();
|
||||
|
||||
if (! $canViewAll || (bool) ($validated['mine'] ?? false)) {
|
||||
$query->where('user_id', $user->id);
|
||||
}
|
||||
|
||||
if (! empty($validated['status'])) {
|
||||
$query->where('status', $validated['status']);
|
||||
}
|
||||
|
||||
if (! empty($validated['category_id'])) {
|
||||
$query->where('ticket_category_id', (int) $validated['category_id']);
|
||||
}
|
||||
|
||||
$tickets = $query->paginate((int) ($validated['per_page'] ?? 20));
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $tickets]);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('提交工单'), Apidoc\Method('POST'), Apidoc\Url('/tickets')]
|
||||
public function store(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'ticket_category_id' => ['required', 'integer', 'exists:ticket_categories,id'],
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
$category = TicketCategory::query()
|
||||
->where('is_active', true)
|
||||
->findOrFail((int) $validated['ticket_category_id']);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth('api')->user();
|
||||
$ticket = Ticket::query()->create([
|
||||
'user_id' => $user->id,
|
||||
'ticket_category_id' => $category->id,
|
||||
'title' => $validated['title'],
|
||||
'content' => $validated['content'],
|
||||
'status' => Ticket::StatusOpen,
|
||||
'last_replied_at' => now(),
|
||||
]);
|
||||
$ticket->messages()->create([
|
||||
'user_id' => $user->id,
|
||||
'sender_type' => TicketMessage::SenderUser,
|
||||
'content' => $validated['content'],
|
||||
]);
|
||||
|
||||
$this->auditLog($request, 'ticket_create', ['metadata' => ['ticket_id' => $ticket->id]]);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $ticket->load(['user', 'category', 'messages.user'])], 201);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('工单详情'), Apidoc\Method('GET'), Apidoc\Url('/tickets/{id}')]
|
||||
public function show(int $id): JsonResponse
|
||||
{
|
||||
$ticket = Ticket::query()
|
||||
->with(['user:id,nickname,email,phone', 'category', 'assignedUser:id,nickname,email', 'messages.user:id,nickname,email'])
|
||||
->findOrFail($id);
|
||||
|
||||
$this->authorizeTicketAccess($ticket);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $ticket]);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('更新工单状态'), Apidoc\Method('PUT'), Apidoc\Url('/tickets/{id}')]
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$ticket = Ticket::query()->findOrFail($id);
|
||||
$validated = $request->validate([
|
||||
'status' => ['required', 'string', Rule::in(Ticket::STATUSES)],
|
||||
'ticket_category_id' => ['nullable', 'integer', 'exists:ticket_categories,id'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth('api')->user();
|
||||
$status = (string) $validated['status'];
|
||||
$ticket->fill([
|
||||
'status' => $status,
|
||||
'assigned_user_id' => $user->id,
|
||||
'closed_at' => in_array($status, [Ticket::StatusResolved, Ticket::StatusClosed], true) ? now() : null,
|
||||
]);
|
||||
if (array_key_exists('ticket_category_id', $validated)) {
|
||||
$ticket->ticket_category_id = $validated['ticket_category_id'];
|
||||
}
|
||||
$ticket->save();
|
||||
|
||||
$this->auditLog($request, 'ticket_update', ['metadata' => ['ticket_id' => $ticket->id, 'status' => $status]]);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $ticket->fresh(['user', 'category', 'assignedUser'])]);
|
||||
}
|
||||
|
||||
#[Apidoc\Title('回复工单'), Apidoc\Method('POST'), Apidoc\Url('/tickets/{id}/messages')]
|
||||
public function reply(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$ticket = Ticket::query()->findOrFail($id);
|
||||
$this->authorizeTicketAccess($ticket);
|
||||
$validated = $request->validate([
|
||||
'content' => ['required', 'string', 'max:10000'],
|
||||
]);
|
||||
|
||||
/** @var User $user */
|
||||
$user = auth('api')->user();
|
||||
$isOwnTicket = (int) $ticket->user_id === (int) $user->id;
|
||||
$isAdminReply = $this->canManageTickets($user);
|
||||
if (! $isAdminReply && (! $isOwnTicket || ! $user->can('tickets.use'))) {
|
||||
abort(403, '无权限回复该工单。');
|
||||
}
|
||||
|
||||
$message = $ticket->messages()->create([
|
||||
'user_id' => $user->id,
|
||||
'sender_type' => $isAdminReply ? TicketMessage::SenderAdmin : TicketMessage::SenderUser,
|
||||
'content' => $validated['content'],
|
||||
]);
|
||||
$ticket->update([
|
||||
'assigned_user_id' => $isAdminReply ? $user->id : $ticket->assigned_user_id,
|
||||
'status' => $isAdminReply && $ticket->status === Ticket::StatusOpen ? Ticket::StatusProcessing : $ticket->status,
|
||||
'last_replied_at' => now(),
|
||||
]);
|
||||
|
||||
$this->auditLog($request, 'ticket_reply', ['metadata' => ['ticket_id' => $ticket->id, 'ticket_message_id' => $message->id]]);
|
||||
|
||||
return response()->json(['code' => 0, 'message' => 'ok', 'data' => $message->load('user')], 201);
|
||||
}
|
||||
|
||||
private function authorizeTicketAccess(Ticket $ticket): void
|
||||
{
|
||||
/** @var User $user */
|
||||
$user = auth('api')->user();
|
||||
if ($this->canViewAllTickets($user) || (int) $ticket->user_id === (int) $user->id) {
|
||||
return;
|
||||
}
|
||||
|
||||
abort(403, '无权限查看该工单。');
|
||||
}
|
||||
|
||||
private function canViewAllTickets(User $user): bool
|
||||
{
|
||||
return $user->can('platform.tickets.view') || $user->can('platform.tickets.manage');
|
||||
}
|
||||
|
||||
private function canManageTickets(User $user): bool
|
||||
{
|
||||
return $user->can('platform.tickets.manage');
|
||||
}
|
||||
}
|
||||
@ -57,7 +57,7 @@ class UserController extends Controller
|
||||
$user = User::query()->create($data);
|
||||
|
||||
if ($request->filled('role_ids')) {
|
||||
$user->syncRoles($request->validated('role_ids'));
|
||||
$user->syncRoles($this->normalizeRoleIds($request->validated('role_ids')));
|
||||
}
|
||||
|
||||
if ($request->filled('server_bindings')) {
|
||||
@ -98,7 +98,7 @@ class UserController extends Controller
|
||||
$user->save();
|
||||
|
||||
if ($request->has('role_ids')) {
|
||||
$user->syncRoles($request->validated('role_ids'));
|
||||
$user->syncRoles($this->normalizeRoleIds($request->validated('role_ids')));
|
||||
}
|
||||
|
||||
if ($request->has('server_bindings')) {
|
||||
@ -199,7 +199,7 @@ class UserController extends Controller
|
||||
|
||||
$users = User::query()->whereIn('id', $userIds)->get();
|
||||
foreach ($users as $user) {
|
||||
$user->syncRoles($roleIds);
|
||||
$user->syncRoles($this->normalizeRoleIds($roleIds));
|
||||
$user->syncPermissions($permissionIds);
|
||||
$this->syncServerResourcePermissionsByDirectPermissions($user, $permissionIds);
|
||||
}
|
||||
@ -285,7 +285,7 @@ class UserController extends Controller
|
||||
$user = User::query()->create($payload);
|
||||
$roleIds = $this->resolveRoleIds($normalizedRow);
|
||||
if (! empty($roleIds)) {
|
||||
$user->syncRoles($roleIds);
|
||||
$user->syncRoles($this->normalizeRoleIds($roleIds));
|
||||
}
|
||||
$createdCount++;
|
||||
}
|
||||
@ -620,6 +620,35 @@ class UserController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
private function normalizeRoleIds(array $roleIds): array
|
||||
{
|
||||
$ids = collect($roleIds)
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->unique()
|
||||
->values();
|
||||
|
||||
if ($ids->isEmpty()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$roles = Role::query()
|
||||
->whereIn('id', $ids)
|
||||
->get(['id', 'name']);
|
||||
|
||||
$hasNonGuestRole = $roles->contains(fn (Role $role): bool => (string) $role->name !== 'guest');
|
||||
if (! $hasNonGuestRole) {
|
||||
return $ids->all();
|
||||
}
|
||||
|
||||
$guestRoleIds = $roles
|
||||
->filter(fn (Role $role): bool => (string) $role->name === 'guest')
|
||||
->pluck('id')
|
||||
->map(fn ($id): int => (int) $id)
|
||||
->all();
|
||||
|
||||
return $ids->reject(fn (int $id): bool => in_array($id, $guestRoleIds, true))->values()->all();
|
||||
}
|
||||
|
||||
private function deleteServerBindingsPayload(User $user, array $unbindings): void
|
||||
{
|
||||
foreach ($unbindings as $unbinding) {
|
||||
|
||||
@ -19,6 +19,7 @@ class StoreUserRequest extends FormRequest
|
||||
'phone' => ['nullable', 'string', 'max:32', 'unique:users,phone'],
|
||||
'password' => ['required', 'string', 'min:6'],
|
||||
'force_password_change' => ['sometimes', 'boolean'],
|
||||
'application_note' => ['nullable', 'string', 'max:2000'],
|
||||
'role_ids' => ['sometimes', 'array'],
|
||||
'role_ids.*' => ['integer', 'exists:roles,id'],
|
||||
'server_bindings' => ['sometimes', 'array'],
|
||||
|
||||
@ -22,6 +22,7 @@ class UpdateUserRequest extends FormRequest
|
||||
'phone' => ['nullable', 'string', 'max:32', Rule::unique('users', 'phone')->ignore($userId)],
|
||||
'password' => ['sometimes', 'required', 'string', 'min:6'],
|
||||
'force_password_change' => ['sometimes', 'boolean'],
|
||||
'application_note' => ['nullable', 'string', 'max:2000'],
|
||||
'role_ids' => ['sometimes', 'array'],
|
||||
'role_ids.*' => ['integer', 'exists:roles,id'],
|
||||
'server_bindings' => ['sometimes', 'array'],
|
||||
|
||||
67
app/Models/Ticket.php
Normal file
67
app/Models/Ticket.php
Normal file
@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class Ticket extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const StatusOpen = 'open';
|
||||
|
||||
public const StatusProcessing = 'processing';
|
||||
|
||||
public const StatusResolved = 'resolved';
|
||||
|
||||
public const StatusClosed = 'closed';
|
||||
|
||||
public const STATUSES = [
|
||||
self::StatusOpen,
|
||||
self::StatusProcessing,
|
||||
self::StatusResolved,
|
||||
self::StatusClosed,
|
||||
];
|
||||
|
||||
protected $fillable = [
|
||||
'user_id',
|
||||
'ticket_category_id',
|
||||
'assigned_user_id',
|
||||
'title',
|
||||
'content',
|
||||
'status',
|
||||
'last_replied_at',
|
||||
'closed_at',
|
||||
];
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
|
||||
public function category(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketCategory::class, 'ticket_category_id');
|
||||
}
|
||||
|
||||
public function assignedUser(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class, 'assigned_user_id');
|
||||
}
|
||||
|
||||
public function messages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketMessage::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'last_replied_at' => 'datetime',
|
||||
'closed_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Models/TicketCategory.php
Normal file
43
app/Models/TicketCategory.php
Normal file
@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
class TicketCategory extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'parent_id',
|
||||
'name',
|
||||
'description',
|
||||
'is_active',
|
||||
];
|
||||
|
||||
public function parent(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(TicketCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function children(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketCategory::class, 'parent_id');
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'parent_id' => 'integer',
|
||||
'is_active' => 'boolean',
|
||||
];
|
||||
}
|
||||
}
|
||||
33
app/Models/TicketMessage.php
Normal file
33
app/Models/TicketMessage.php
Normal file
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
class TicketMessage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const SenderUser = 'user';
|
||||
|
||||
public const SenderAdmin = 'admin';
|
||||
|
||||
protected $fillable = [
|
||||
'ticket_id',
|
||||
'user_id',
|
||||
'sender_type',
|
||||
'content',
|
||||
];
|
||||
|
||||
public function ticket(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(Ticket::class);
|
||||
}
|
||||
|
||||
public function user(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(User::class);
|
||||
}
|
||||
}
|
||||
@ -24,6 +24,7 @@ class User extends Authenticatable implements JWTSubject
|
||||
'phone',
|
||||
'password',
|
||||
'force_password_change',
|
||||
'application_note',
|
||||
];
|
||||
|
||||
protected $hidden = [
|
||||
@ -58,6 +59,16 @@ class User extends Authenticatable implements JWTSubject
|
||||
return $this->hasMany(UserOpsSoftwarePreference::class);
|
||||
}
|
||||
|
||||
public function tickets(): HasMany
|
||||
{
|
||||
return $this->hasMany(Ticket::class);
|
||||
}
|
||||
|
||||
public function ticketMessages(): HasMany
|
||||
{
|
||||
return $this->hasMany(TicketMessage::class);
|
||||
}
|
||||
|
||||
public function isAdmin(): bool
|
||||
{
|
||||
return $this->hasRole('admin', 'api');
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_categories', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('parent_id')->nullable()->constrained('ticket_categories')->nullOnDelete();
|
||||
$table->string('name');
|
||||
$table->string('description')->nullable();
|
||||
$table->boolean('is_active')->default(true)->index();
|
||||
$table->timestamps();
|
||||
|
||||
$table->unique(['parent_id', 'name']);
|
||||
$table->index(['parent_id', 'is_active']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_categories');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('tickets', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('user_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('ticket_category_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->foreignId('assigned_user_id')->nullable()->constrained('users')->nullOnDelete();
|
||||
$table->string('title');
|
||||
$table->text('content');
|
||||
$table->string('status', 32)->default('open')->index();
|
||||
$table->timestamp('last_replied_at')->nullable()->index();
|
||||
$table->timestamp('closed_at')->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['user_id', 'status']);
|
||||
$table->index(['ticket_category_id', 'status']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('tickets');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ticket_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('ticket_id')->constrained()->cascadeOnDelete();
|
||||
$table->foreignId('user_id')->nullable()->constrained()->nullOnDelete();
|
||||
$table->string('sender_type', 32);
|
||||
$table->text('content');
|
||||
$table->timestamps();
|
||||
|
||||
$table->index(['ticket_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ticket_messages');
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (Schema::hasColumn('ticket_categories', 'parent_id') || ! Schema::hasColumn('ticket_categories', 'sort_order')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->foreignId('parent_id')
|
||||
->nullable()
|
||||
->after('id')
|
||||
->constrained('ticket_categories')
|
||||
->nullOnDelete();
|
||||
});
|
||||
|
||||
if ($this->indexExists('ticket_categories_name_unique')) {
|
||||
DB::statement('ALTER TABLE ticket_categories DROP INDEX ticket_categories_name_unique');
|
||||
}
|
||||
|
||||
if (Schema::hasColumn('ticket_categories', 'sort_order')) {
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->dropIndex('ticket_categories_sort_order_index');
|
||||
$table->dropColumn('sort_order');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->unique(['parent_id', 'name'], 'ticket_categories_parent_id_name_unique');
|
||||
$table->index(['parent_id', 'is_active'], 'ticket_categories_parent_id_is_active_index');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (! Schema::hasColumn('ticket_categories', 'parent_id') || ! $this->indexExists('ticket_categories_name_unique')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->dropUnique('ticket_categories_parent_id_name_unique');
|
||||
$table->dropIndex('ticket_categories_parent_id_is_active_index');
|
||||
});
|
||||
|
||||
if (! Schema::hasColumn('ticket_categories', 'sort_order')) {
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->unsignedInteger('sort_order')->default(0)->index()->after('description');
|
||||
});
|
||||
}
|
||||
|
||||
Schema::table('ticket_categories', function (Blueprint $table) {
|
||||
$table->dropConstrainedForeignId('parent_id');
|
||||
$table->unique('name', 'ticket_categories_name_unique');
|
||||
});
|
||||
}
|
||||
|
||||
private function indexExists(string $indexName): bool
|
||||
{
|
||||
if (DB::getDriverName() !== 'mysql') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return count(DB::select('SHOW INDEX FROM ticket_categories WHERE Key_name = ?', [$indexName])) > 0;
|
||||
}
|
||||
};
|
||||
@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->text('application_note')->nullable()->after('force_password_change');
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('users', function (Blueprint $table) {
|
||||
$table->dropColumn('application_note');
|
||||
});
|
||||
}
|
||||
};
|
||||
@ -49,4 +49,83 @@ class SsoApiTest extends TestCase
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['email', 'phone']);
|
||||
}
|
||||
|
||||
public function test_apply_account_assigns_guest_role(): void
|
||||
{
|
||||
$response = $this->postJson('/auth/apply-account', [
|
||||
'nickname' => 'Guest User',
|
||||
'email' => 'guest@example.com',
|
||||
'phone' => '13800138000',
|
||||
'password' => 'secret123',
|
||||
'password_confirmation' => 'secret123',
|
||||
'application_note' => '需要访问服务器资源',
|
||||
]);
|
||||
|
||||
$response->assertCreated();
|
||||
$user = User::query()->where('email', 'guest@example.com')->firstOrFail();
|
||||
|
||||
$this->assertTrue($user->hasRole('guest', 'api'));
|
||||
$this->assertSame('需要访问服务器资源', $user->application_note);
|
||||
}
|
||||
|
||||
public function test_apply_account_updates_existing_guest_application_by_email_or_phone(): void
|
||||
{
|
||||
$guest = Role::query()->firstOrCreate(['name' => 'guest', 'guard_name' => 'api']);
|
||||
$user = User::factory()->create([
|
||||
'nickname' => 'Old Name',
|
||||
'email' => 'old@example.com',
|
||||
'phone' => '13800138001',
|
||||
'application_note' => '旧备注',
|
||||
]);
|
||||
$user->assignRole($guest);
|
||||
|
||||
$response = $this->postJson('/auth/apply-account', [
|
||||
'nickname' => 'New Name',
|
||||
'email' => 'new@example.com',
|
||||
'phone' => '13800138001',
|
||||
'password' => 'newsecret',
|
||||
'password_confirmation' => 'newsecret',
|
||||
'application_note' => '新备注',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertOk()
|
||||
->assertJsonPath('data.updated', true);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('New Name', $user->nickname);
|
||||
$this->assertSame('new@example.com', $user->email);
|
||||
$this->assertSame('新备注', $user->application_note);
|
||||
$this->assertTrue($user->hasRole('guest', 'api'));
|
||||
}
|
||||
|
||||
public function test_apply_account_rejects_existing_non_guest_account_without_modifying_it(): void
|
||||
{
|
||||
$role = Role::query()->firstOrCreate(['name' => 'user', 'guard_name' => 'api']);
|
||||
$user = User::factory()->create([
|
||||
'nickname' => 'Opened User',
|
||||
'email' => 'opened@example.com',
|
||||
'phone' => '13900139000',
|
||||
'application_note' => '原备注',
|
||||
]);
|
||||
$user->assignRole($role);
|
||||
|
||||
$response = $this->postJson('/auth/apply-account', [
|
||||
'nickname' => 'Should Not Save',
|
||||
'email' => 'opened@example.com',
|
||||
'phone' => '13800138002',
|
||||
'password' => 'secret123',
|
||||
'password_confirmation' => 'secret123',
|
||||
'application_note' => '新备注',
|
||||
]);
|
||||
|
||||
$response
|
||||
->assertStatus(422)
|
||||
->assertJsonValidationErrors(['account']);
|
||||
|
||||
$user->refresh();
|
||||
$this->assertSame('Opened User', $user->nickname);
|
||||
$this->assertSame('13900139000', $user->phone);
|
||||
$this->assertSame('原备注', $user->application_note);
|
||||
}
|
||||
}
|
||||
|
||||
159
tests/Feature/TicketFeatureTest.php
Normal file
159
tests/Feature/TicketFeatureTest.php
Normal file
@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\Ticket;
|
||||
use App\Models\TicketCategory;
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class TicketFeatureTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_user_can_create_and_reply_to_own_ticket(): void
|
||||
{
|
||||
$user = $this->userWithPermissions(['tickets.use']);
|
||||
$category = TicketCategory::query()->create(['name' => '账号问题', 'is_active' => true]);
|
||||
|
||||
$create = $this->actingAs($user, 'api')->postJson('/tickets', [
|
||||
'ticket_category_id' => $category->id,
|
||||
'title' => '无法登录',
|
||||
'content' => '登录失败',
|
||||
]);
|
||||
|
||||
$create
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.title', '无法登录')
|
||||
->assertJsonPath('data.messages.0.content', '登录失败');
|
||||
|
||||
$ticketId = (int) $create->json('data.id');
|
||||
$reply = $this->actingAs($user, 'api')->postJson('/tickets/'.$ticketId.'/messages', [
|
||||
'content' => '补充信息',
|
||||
]);
|
||||
|
||||
$reply
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.sender_type', 'user');
|
||||
|
||||
$detail = $this->actingAs($user, 'api')->getJson('/tickets/'.$ticketId);
|
||||
$detail
|
||||
->assertOk()
|
||||
->assertJsonPath('data.messages.1.content', '补充信息');
|
||||
}
|
||||
|
||||
public function test_user_cannot_view_other_users_ticket(): void
|
||||
{
|
||||
$owner = $this->userWithPermissions(['tickets.use']);
|
||||
$other = $this->userWithPermissions(['tickets.use']);
|
||||
$category = TicketCategory::query()->create(['name' => '资源申请', 'is_active' => true]);
|
||||
$ticket = Ticket::query()->create([
|
||||
'user_id' => $owner->id,
|
||||
'ticket_category_id' => $category->id,
|
||||
'title' => '申请资源',
|
||||
'content' => '需要资源',
|
||||
'status' => Ticket::StatusOpen,
|
||||
]);
|
||||
|
||||
$response = $this->actingAs($other, 'api')->getJson('/tickets/'.$ticket->id);
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
public function test_admin_can_manage_categories_update_and_reply_ticket(): void
|
||||
{
|
||||
$admin = $this->userWithPermissions(['platform.tickets.view', 'platform.tickets.manage']);
|
||||
$owner = $this->userWithPermissions(['tickets.use']);
|
||||
$category = TicketCategory::query()->create(['name' => '旧分类', 'is_active' => true]);
|
||||
$ticket = Ticket::query()->create([
|
||||
'user_id' => $owner->id,
|
||||
'ticket_category_id' => $category->id,
|
||||
'title' => '问题',
|
||||
'content' => '内容',
|
||||
'status' => Ticket::StatusOpen,
|
||||
]);
|
||||
|
||||
$createdCategory = $this->actingAs($admin, 'api')->postJson('/ticket-categories', [
|
||||
'name' => '新分类',
|
||||
'description' => '分类说明',
|
||||
'is_active' => true,
|
||||
]);
|
||||
$createdCategory->assertCreated()->assertJsonPath('data.name', '新分类');
|
||||
|
||||
$update = $this->actingAs($admin, 'api')->putJson('/tickets/'.$ticket->id, [
|
||||
'status' => Ticket::StatusResolved,
|
||||
'ticket_category_id' => $createdCategory->json('data.id'),
|
||||
]);
|
||||
$update
|
||||
->assertOk()
|
||||
->assertJsonPath('data.status', Ticket::StatusResolved);
|
||||
|
||||
$reply = $this->actingAs($admin, 'api')->postJson('/tickets/'.$ticket->id.'/messages', [
|
||||
'content' => '已处理',
|
||||
]);
|
||||
$reply
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.sender_type', 'admin');
|
||||
|
||||
$list = $this->actingAs($admin, 'api')->getJson('/tickets');
|
||||
$list->assertOk();
|
||||
$this->assertContains($ticket->id, collect($list->json('data.data'))->pluck('id')->all());
|
||||
}
|
||||
|
||||
public function test_admin_can_manage_nested_ticket_categories(): void
|
||||
{
|
||||
$admin = $this->userWithPermissions(['platform.tickets.manage']);
|
||||
$parent = TicketCategory::query()->create(['name' => '账号问题', 'is_active' => true]);
|
||||
|
||||
$created = $this->actingAs($admin, 'api')->postJson('/ticket-categories', [
|
||||
'parent_id' => $parent->id,
|
||||
'name' => '登录异常',
|
||||
'description' => '登录相关问题',
|
||||
'is_active' => true,
|
||||
]);
|
||||
|
||||
$created
|
||||
->assertCreated()
|
||||
->assertJsonPath('data.parent_id', $parent->id)
|
||||
->assertJsonMissingPath('data.sort_order');
|
||||
|
||||
$list = $this->actingAs($admin, 'api')->getJson('/ticket-categories');
|
||||
$list->assertOk();
|
||||
$parentNode = collect($list->json('tree'))->firstWhere('id', $parent->id);
|
||||
$this->assertSame('账号问题', $parentNode['name']);
|
||||
$this->assertSame('登录异常', $parentNode['children'][0]['name']);
|
||||
}
|
||||
|
||||
public function test_user_without_ticket_permission_is_forbidden(): void
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
|
||||
$response = $this->actingAs($user, 'api')->getJson('/tickets');
|
||||
|
||||
$response->assertStatus(403);
|
||||
}
|
||||
|
||||
private function userWithPermissions(array $permissions): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::query()->create([
|
||||
'name' => 'role-'.strtolower(str()->random(8)),
|
||||
'guard_name' => 'api',
|
||||
]);
|
||||
|
||||
foreach ($permissions as $permissionName) {
|
||||
$permission = Permission::query()->firstOrCreate([
|
||||
'name' => $permissionName,
|
||||
'guard_name' => 'api',
|
||||
]);
|
||||
$role->givePermissionTo($permission);
|
||||
}
|
||||
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
66
tests/Feature/UserRoleManagementTest.php
Normal file
66
tests/Feature/UserRoleManagementTest.php
Normal file
@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Spatie\Permission\Models\Permission;
|
||||
use Spatie\Permission\Models\Role;
|
||||
use Tests\TestCase;
|
||||
|
||||
class UserRoleManagementTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
public function test_setting_non_guest_role_removes_guest_role(): void
|
||||
{
|
||||
$admin = $this->admin();
|
||||
$guest = Role::query()->firstOrCreate(['name' => 'guest', 'guard_name' => 'api']);
|
||||
$userRole = Role::query()->firstOrCreate(['name' => 'user', 'guard_name' => 'api']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($guest);
|
||||
|
||||
$response = $this->actingAs($admin, 'api')->putJson('/users/'.$user->id, [
|
||||
'nickname' => $user->nickname,
|
||||
'email' => $user->email,
|
||||
'phone' => $user->phone,
|
||||
'role_ids' => [$guest->id, $userRole->id],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$user->refresh();
|
||||
$this->assertFalse($user->hasRole('guest', 'api'));
|
||||
$this->assertTrue($user->hasRole('user', 'api'));
|
||||
}
|
||||
|
||||
public function test_batch_setting_non_guest_role_removes_guest_role(): void
|
||||
{
|
||||
$admin = $this->admin();
|
||||
$guest = Role::query()->firstOrCreate(['name' => 'guest', 'guard_name' => 'api']);
|
||||
$userRole = Role::query()->firstOrCreate(['name' => 'user', 'guard_name' => 'api']);
|
||||
$user = User::factory()->create();
|
||||
$user->assignRole($guest);
|
||||
|
||||
$response = $this->actingAs($admin, 'api')->putJson('/users/batch-assignments', [
|
||||
'user_ids' => [$user->id],
|
||||
'role_ids' => [$guest->id, $userRole->id],
|
||||
'permission_ids' => [],
|
||||
]);
|
||||
|
||||
$response->assertOk();
|
||||
$user->refresh();
|
||||
$this->assertFalse($user->hasRole('guest', 'api'));
|
||||
$this->assertTrue($user->hasRole('user', 'api'));
|
||||
}
|
||||
|
||||
private function admin(): User
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
$role = Role::query()->create(['name' => 'admin-'.strtolower(str()->random(8)), 'guard_name' => 'api']);
|
||||
$permission = Permission::query()->firstOrCreate(['name' => 'platform.users.manage', 'guard_name' => 'api']);
|
||||
$role->givePermissionTo($permission);
|
||||
$user->assignRole($role);
|
||||
|
||||
return $user;
|
||||
}
|
||||
}
|
||||
@ -39,6 +39,7 @@ class CommandExecutor:
|
||||
class CliSystemProvider(SystemProvider):
|
||||
managed_start_marker = "# >>> BastionSSO environment >>>"
|
||||
managed_end_marker = "# <<< BastionSSO environment <<<"
|
||||
bash_profile_content = "if [ -f ~/.bashrc ]; then\n . ~/.bashrc\nfi\n"
|
||||
|
||||
def __init__(self, executor: CommandExecutor):
|
||||
self.executor = executor
|
||||
@ -61,6 +62,7 @@ class CliSystemProvider(SystemProvider):
|
||||
self.executor.run(["mkdir", "-p", linked_home_dir])
|
||||
self.executor.run(["chown", "-R", username, linked_home_dir])
|
||||
self.executor.run(["ln", "-s", linked_home_dir, home_dir])
|
||||
self._write_user_bash_profile(username)
|
||||
|
||||
def delete_user(self, username: str) -> None:
|
||||
home_dir = self.get_user(username).home_dir
|
||||
@ -147,17 +149,23 @@ class CliSystemProvider(SystemProvider):
|
||||
self._write_user_bashrc(username, next_content)
|
||||
|
||||
def _write_user_bashrc(self, username: str, content: str) -> None:
|
||||
self._write_user_file(username, ".bashrc", content)
|
||||
|
||||
def _write_user_bash_profile(self, username: str) -> None:
|
||||
self._write_user_file(username, ".bash_profile", self.bash_profile_content)
|
||||
|
||||
def _write_user_file(self, username: str, filename: str, content: str) -> None:
|
||||
user = self.get_user(username)
|
||||
home_dir = PurePosixPath(user.home_dir)
|
||||
self.executor.run(["mkdir", "-p", str(home_dir)])
|
||||
bashrc = home_dir / ".bashrc"
|
||||
target = home_dir / filename
|
||||
temp_path = None
|
||||
try:
|
||||
with tempfile.NamedTemporaryFile("w", encoding="utf-8", delete=False) as temp_file:
|
||||
temp_file.write(content)
|
||||
temp_path = temp_file.name
|
||||
|
||||
self.executor.run(["install", "-m", "644", "-o", username, "-g", str(user.gid), temp_path, str(bashrc)])
|
||||
self.executor.run(["install", "-m", "644", "-o", username, "-g", str(user.gid), temp_path, str(target)])
|
||||
finally:
|
||||
if temp_path:
|
||||
Path(temp_path).unlink(missing_ok=True)
|
||||
|
||||
@ -102,6 +102,11 @@ class RecordingExecutor:
|
||||
raise ApiError(404, "not_found", "No such file or directory")
|
||||
|
||||
return self.files["bashrc"]
|
||||
if args[:1] == ["install"]:
|
||||
target = args[-1]
|
||||
source = args[-2]
|
||||
with open(source, encoding="utf-8") as source_file:
|
||||
self.files[target] = source_file.read()
|
||||
|
||||
return ""
|
||||
|
||||
@ -188,6 +193,26 @@ def test_write_user_environment_uses_install_for_bashrc_only() -> None:
|
||||
assert not any(command[:3] == ["chown", "-R", "alice"] for command in executor.commands)
|
||||
|
||||
|
||||
def test_create_user_writes_bash_profile_that_loads_bashrc() -> None:
|
||||
executor = RecordingExecutor("/home/alice")
|
||||
provider = CliSystemProvider(executor=executor)
|
||||
|
||||
provider.create_user(
|
||||
username="alice",
|
||||
password_hash="$6$rounds=5000$abcdefghij",
|
||||
home_dir="/home/alice",
|
||||
linked_home_dir=None,
|
||||
shell="/bin/bash",
|
||||
primary_group=None,
|
||||
groups=[],
|
||||
)
|
||||
|
||||
assert ["useradd", "-s", "/bin/bash", "-p", "$6$rounds=5000$abcdefghij", "-m", "-d", "/home/alice", "alice"] in executor.commands
|
||||
install_command = next(command for command in executor.commands if command[:7] == ["install", "-m", "644", "-o", "alice", "-g", "1000"])
|
||||
assert install_command[-1] == "/home/alice/.bash_profile"
|
||||
assert executor.files["/home/alice/.bash_profile"] == "if [ -f ~/.bashrc ]; then\n . ~/.bashrc\nfi\n"
|
||||
|
||||
|
||||
def test_all_user_environments_collects_partial_failures() -> None:
|
||||
class PartialFailureProvider(NoopProvider):
|
||||
def list_users(self):
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user