feat(工单): 完成工单系统的编写

This commit is contained in:
Boen_Shi 2026-06-18 23:16:44 +08:00
parent 47d4749afb
commit b02ff30ef2
22 changed files with 1166 additions and 14 deletions

View File

@ -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',
]);

View File

@ -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);
}

View File

@ -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' => '发起服务器资源访问与连接操作'],
];

View 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' => ['同级分类下已存在相同名称。'],
]);
}
}
}

View 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');
}
}

View File

@ -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) {

View File

@ -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'],

View File

@ -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
View 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',
];
}
}

View 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',
];
}
}

View 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);
}
}

View File

@ -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');

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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');
}
};

View File

@ -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;
}
};

View File

@ -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');
});
}
};

View File

@ -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);
}
}

View 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;
}
}

View 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;
}
}

View File

@ -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)

View File

@ -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):