diff --git a/app/Console/Commands/UserManageCommand.php b/app/Console/Commands/UserManageCommand.php index d72c9a3..37287ee 100644 --- a/app/Console/Commands/UserManageCommand.php +++ b/app/Console/Commands/UserManageCommand.php @@ -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', ]); diff --git a/app/Http/Controllers/Api/AuthController.php b/app/Http/Controllers/Api/AuthController.php index 44e9b92..4f0ae3b 100644 --- a/app/Http/Controllers/Api/AuthController.php +++ b/app/Http/Controllers/Api/AuthController.php @@ -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); } diff --git a/app/Http/Controllers/Api/PermissionController.php b/app/Http/Controllers/Api/PermissionController.php index c3ce5a7..2ff3838 100644 --- a/app/Http/Controllers/Api/PermissionController.php +++ b/app/Http/Controllers/Api/PermissionController.php @@ -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' => '发起服务器资源访问与连接操作'], ]; diff --git a/app/Http/Controllers/Api/TicketCategoryController.php b/app/Http/Controllers/Api/TicketCategoryController.php new file mode 100644 index 0000000..c9c44c1 --- /dev/null +++ b/app/Http/Controllers/Api/TicketCategoryController.php @@ -0,0 +1,180 @@ +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' => ['同级分类下已存在相同名称。'], + ]); + } + } +} diff --git a/app/Http/Controllers/Api/TicketController.php b/app/Http/Controllers/Api/TicketController.php new file mode 100644 index 0000000..d41615a --- /dev/null +++ b/app/Http/Controllers/Api/TicketController.php @@ -0,0 +1,189 @@ +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'); + } +} diff --git a/app/Http/Controllers/Api/UserController.php b/app/Http/Controllers/Api/UserController.php index 4c575b8..6927207 100644 --- a/app/Http/Controllers/Api/UserController.php +++ b/app/Http/Controllers/Api/UserController.php @@ -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) { diff --git a/app/Http/Requests/StoreUserRequest.php b/app/Http/Requests/StoreUserRequest.php index bebc043..cfa691d 100644 --- a/app/Http/Requests/StoreUserRequest.php +++ b/app/Http/Requests/StoreUserRequest.php @@ -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'], diff --git a/app/Http/Requests/UpdateUserRequest.php b/app/Http/Requests/UpdateUserRequest.php index a97c55d..598ecfa 100644 --- a/app/Http/Requests/UpdateUserRequest.php +++ b/app/Http/Requests/UpdateUserRequest.php @@ -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'], diff --git a/app/Models/Ticket.php b/app/Models/Ticket.php new file mode 100644 index 0000000..07dd95c --- /dev/null +++ b/app/Models/Ticket.php @@ -0,0 +1,67 @@ +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', + ]; + } +} diff --git a/app/Models/TicketCategory.php b/app/Models/TicketCategory.php new file mode 100644 index 0000000..1dcf247 --- /dev/null +++ b/app/Models/TicketCategory.php @@ -0,0 +1,43 @@ +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', + ]; + } +} diff --git a/app/Models/TicketMessage.php b/app/Models/TicketMessage.php new file mode 100644 index 0000000..a38168a --- /dev/null +++ b/app/Models/TicketMessage.php @@ -0,0 +1,33 @@ +belongsTo(Ticket::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index a5aaa59..29cdf96 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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'); diff --git a/database/migrations/2026_06_18_064548_create_ticket_categories_table.php b/database/migrations/2026_06_18_064548_create_ticket_categories_table.php new file mode 100644 index 0000000..fe1b938 --- /dev/null +++ b/database/migrations/2026_06_18_064548_create_ticket_categories_table.php @@ -0,0 +1,34 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_18_064549_create_tickets_table.php b/database/migrations/2026_06_18_064549_create_tickets_table.php new file mode 100644 index 0000000..5c5847c --- /dev/null +++ b/database/migrations/2026_06_18_064549_create_tickets_table.php @@ -0,0 +1,38 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_18_064550_create_ticket_messages_table.php b/database/migrations/2026_06_18_064550_create_ticket_messages_table.php new file mode 100644 index 0000000..ef8a815 --- /dev/null +++ b/database/migrations/2026_06_18_064550_create_ticket_messages_table.php @@ -0,0 +1,33 @@ +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'); + } +}; diff --git a/database/migrations/2026_06_18_073434_update_ticket_categories_for_nested_categories.php b/database/migrations/2026_06_18_073434_update_ticket_categories_for_nested_categories.php new file mode 100644 index 0000000..62ca9f5 --- /dev/null +++ b/database/migrations/2026_06_18_073434_update_ticket_categories_for_nested_categories.php @@ -0,0 +1,78 @@ +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; + } +}; diff --git a/database/migrations/2026_06_18_121739_add_application_note_to_users_table.php b/database/migrations/2026_06_18_121739_add_application_note_to_users_table.php new file mode 100644 index 0000000..dc38c25 --- /dev/null +++ b/database/migrations/2026_06_18_121739_add_application_note_to_users_table.php @@ -0,0 +1,28 @@ +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'); + }); + } +}; diff --git a/tests/Feature/SsoApiTest.php b/tests/Feature/SsoApiTest.php index 77c925e..53726de 100644 --- a/tests/Feature/SsoApiTest.php +++ b/tests/Feature/SsoApiTest.php @@ -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); + } } diff --git a/tests/Feature/TicketFeatureTest.php b/tests/Feature/TicketFeatureTest.php new file mode 100644 index 0000000..94a311b --- /dev/null +++ b/tests/Feature/TicketFeatureTest.php @@ -0,0 +1,159 @@ +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; + } +} diff --git a/tests/Feature/UserRoleManagementTest.php b/tests/Feature/UserRoleManagementTest.php new file mode 100644 index 0000000..02f385e --- /dev/null +++ b/tests/Feature/UserRoleManagementTest.php @@ -0,0 +1,66 @@ +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; + } +} diff --git a/user_manage_api/app/providers/cli_provider.py b/user_manage_api/app/providers/cli_provider.py index cce1cec..72a3b97 100644 --- a/user_manage_api/app/providers/cli_provider.py +++ b/user_manage_api/app/providers/cli_provider.py @@ -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) diff --git a/user_manage_api/tests/test_service_unit.py b/user_manage_api/tests/test_service_unit.py index 339e1b6..d59a6db 100644 --- a/user_manage_api/tests/test_service_unit.py +++ b/user_manage_api/tests/test_service_unit.py @@ -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):