success([ 'tree' => PermissionCatalog::tree(), 'menus' => PermissionCatalog::menuMeta(), 'list' => PermissionCatalog::flatList(), ]); } public function createPermission() { AdminScopeService::requireAny($this->authUser(), ['btn:perm:create', 'menu:permissions', 'can_manage_permissions']); $input = $this->request->post(); $type = $input['type'] ?? ''; $name = trim($input['name'] ?? ''); $code = trim($input['code'] ?? ''); if (!in_array($type, ['dir', 'menu', 'btn'], true)) { return $this->error('类型必须是 dir / menu / btn'); } if ($name === '') { return $this->error('请填写名称'); } if ($code === '') { $prefix = $type === 'dir' ? 'dir:' : ($type === 'menu' ? 'menu:' : 'btn:'); $code = $prefix . preg_replace('/[^a-z0-9_]+/i', '_', strtolower($name)) . '_' . substr(uniqid(), -4); } if (SysPermission::where('code', $code)->find()) { return $this->error('权限标识已存在'); } $parentId = $input['parent_id'] ?? null; if ($parentId !== null && $parentId !== '') { $parentId = (int) $parentId; $parent = SysPermission::find($parentId); if (!$parent) { return $this->error('上级节点不存在'); } if ($type === 'menu' && $parent->type !== 'dir') { return $this->error('菜单的上级必须是目录'); } if ($type === 'btn' && $parent->type !== 'menu') { return $this->error('按钮的上级必须是菜单'); } if ($type === 'dir' && $parentId) { return $this->error('目录不能有上级'); } } else { $parentId = null; if ($type !== 'dir') { return $this->error('菜单/按钮必须指定上级'); } } $row = SysPermission::create([ 'type' => $type, 'code' => $code, 'name' => $name, 'parent_id' => $parentId, 'path' => $type === 'menu' ? trim((string) ($input['path'] ?? '')) : null, 'icon' => $type === 'menu' ? trim((string) ($input['icon'] ?? '')) : null, 'sort_order' => (int) ($input['sort_order'] ?? 0), 'is_system' => 0, ]); return $this->success(['id' => $row->id], '创建成功'); } public function updatePermission($id) { AdminScopeService::requireAny($this->authUser(), ['btn:perm:edit', 'menu:permissions', 'can_manage_permissions']); $row = SysPermission::find((int) $id); if (!$row) { return $this->error('权限不存在', 404); } $input = $this->request->put(); $data = []; foreach (['name', 'path', 'icon', 'sort_order'] as $field) { if (array_key_exists($field, $input)) { $data[$field] = $input[$field]; } } if (isset($input['code']) && trim((string) $input['code']) !== '' && trim((string) $input['code']) !== $row->code) { if ((int) $row->is_system === 1) { return $this->error('系统内置权限标识不可修改'); } $code = trim((string) $input['code']); if (SysPermission::where('code', $code)->where('id', '<>', $id)->find()) { return $this->error('权限标识已存在'); } $data['code'] = $code; } if (empty($data)) { return $this->error('无更新内容'); } $row->save($data); return $this->success(null, '更新成功'); } public function deletePermission($id) { AdminScopeService::requireAny($this->authUser(), ['btn:perm:delete', 'menu:permissions', 'can_manage_permissions']); $row = SysPermission::find((int) $id); if (!$row) { return $this->error('权限不存在', 404); } if ((int) $row->is_system === 1) { return $this->error('系统内置权限不可删除'); } if (SysPermission::where('parent_id', $id)->count() > 0) { return $this->error('请先删除下级权限'); } SysPermission::destroy((int) $id); return $this->success(null, '删除成功'); } public function users() { AdminScopeService::requireAny($this->authUser(), ['menu:users', 'can_manage_users']); $page = max(1, (int) $this->request->get('page', 1)); $limit = min(50, max(1, (int) $this->request->get('limit', 20))); $departmentId = (int) $this->request->get('department_id', 0); $query = User::alias('u') ->leftJoin('membership_levels ml', 'u.membership_level_id = ml.id') ->leftJoin('roles r', 'u.role_id = r.id') ->leftJoin('departments d', 'u.department_id = d.id') ->field('u.id,u.username,u.email,u.nickname,u.role,u.role_id,u.department_id,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name,r.name as role_name,r.slug as role_slug,d.name as department_name') ->whereRaw('LEFT(u.username, 6) <> ?', ['guest_']) ->order('u.id', 'desc'); AdminScopeService::applyUserScope($query, $this->authUser(), 'u'); if ($departmentId > 0) { $deptIds = DepartmentService::descendantIds($departmentId); $query->whereIn('u.department_id', $deptIds); } $total = (clone $query)->count(); $list = $query->page($page, $limit)->select(); return $this->success(['list' => $list, 'total' => $total]); } public function createUser() { AdminScopeService::requireAny($this->authUser(), ['btn:user:create', 'can_manage_users']); $input = $this->request->post(); $username = trim($input['username'] ?? ''); $email = trim($input['email'] ?? ''); $password = (string) ($input['password'] ?? ''); $nickname = trim($input['nickname'] ?? ''); if (strlen($username) < 3 || strlen($username) > 50) { return $this->error('用户名长度需 3-50 个字符'); } if (str_starts_with(strtolower($username), 'guest_')) { return $this->error('guest_ 为系统访客账号保留前缀'); } if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { return $this->error('邮箱格式不正确'); } if (strlen($password) < 6) { return $this->error('密码至少 6 位'); } if (User::where('username', $username)->whereOr('email', $email)->find()) { return $this->error('用户名或邮箱已存在'); } $roleId = isset($input['role_id']) ? (int) $input['role_id'] : (int) Role::where('slug', 'user')->value('id'); if ($roleId && !Role::find($roleId)) { return $this->error('角色不存在'); } $departmentId = $input['department_id'] ?? null; if ($departmentId !== null && $departmentId !== '') { $departmentId = (int) $departmentId; if ($departmentId > 0 && !Department::find($departmentId)) { return $this->error('部门不存在'); } $departmentId = $departmentId > 0 ? $departmentId : null; } else { $departmentId = null; } $membershipId = (int) ($input['membership_level_id'] ?? 1); if (!MembershipLevel::find($membershipId)) { return $this->error('会员等级不存在'); } $user = User::create([ 'username' => $username, 'email' => $email, 'password_hash' => password_hash($password, PASSWORD_BCRYPT), 'nickname' => $nickname !== '' ? $nickname : $username, 'role_id' => $roleId ?: null, 'department_id' => $departmentId, 'membership_level_id' => $membershipId, 'status' => ($input['status'] ?? 'active') === 'disabled' ? 'disabled' : 'active', ]); if ($roleId) { AdminScopeService::syncLegacyRoleField((int) $user->id, $roleId); } return $this->success(['id' => $user->id], '创建成功'); } public function deleteUser($id) { AdminScopeService::requireAny($this->authUser(), ['btn:user:delete', 'can_manage_users']); $targetId = (int) $id; $auth = $this->authUser(); if ($targetId === (int) $auth['id']) { return $this->error('不能删除当前登录账号'); } if (!AdminScopeService::canViewUser($auth, $targetId)) { return $this->error('无权操作该用户', 403); } $user = User::find($targetId); if (!$user) { return $this->error('用户不存在', 404); } if (str_starts_with($user->username, 'guest_')) { return $this->error('访客请在访客管理中操作', 422); } $roleSlug = Role::where('id', $user->role_id)->value('slug'); if ($roleSlug === 'super_admin') { $superRoleId = (int) Role::where('slug', 'super_admin')->value('id'); $count = User::where('role_id', $superRoleId)->count(); if ($count <= 1) { return $this->error('至少保留一个超级管理员账号'); } } User::destroy($targetId); return $this->success(null, '删除成功'); } public function updateUser($id) { AdminScopeService::requireAny($this->authUser(), ['btn:user:edit', 'menu:users', 'can_manage_users']); $targetId = (int) $id; if (!AdminScopeService::canViewUser($this->authUser(), $targetId)) { return $this->error('无权操作该用户', 403); } $input = $this->request->put(); $allowed = ['nickname', 'role', 'status', 'membership_level_id', 'role_id', 'department_id']; $data = []; foreach ($allowed as $field) { if (array_key_exists($field, $input)) { $data[$field] = $input[$field]; } } if (array_key_exists('password', $input)) { $password = trim((string) $input['password']); if ($password !== '') { AdminScopeService::requireAny($this->authUser(), ['btn:user:reset_password', 'can_manage_users']); if (strlen($password) < 6) { return $this->error('密码至少 6 位'); } $data['password_hash'] = password_hash($password, PASSWORD_BCRYPT); } } if (isset($data['membership_level_id']) && !MembershipLevel::find((int) $data['membership_level_id'])) { return $this->error('会员等级不存在'); } if (isset($data['role_id']) && !Role::find((int) $data['role_id'])) { return $this->error('角色不存在'); } if (array_key_exists('department_id', $data)) { $deptId = $data['department_id']; if ($deptId === '' || $deptId === null) { $data['department_id'] = null; } else { $deptId = (int) $deptId; if ($deptId > 0 && !Department::find($deptId)) { return $this->error('部门不存在'); } $data['department_id'] = $deptId > 0 ? $deptId : null; } } if (empty($data)) { return $this->error('无更新内容'); } $user = User::find($targetId); if (!$user) { return $this->error('用户不存在', 404); } if (str_starts_with($user->username, 'guest_')) { return $this->error('访客请在访客管理中操作', 422); } User::where('id', $targetId)->update($data); if (isset($data['role_id'])) { AdminScopeService::syncLegacyRoleField($targetId, (int) $data['role_id']); } return $this->success(null, '更新成功'); } public function guests() { $auth = $this->authUser(); AdminScopeService::requireAny($auth, ['menu:guests']); $page = max(1, (int) $this->request->get('page', 1)); $limit = min(50, max(1, (int) $this->request->get('limit', 20))); $status = trim((string) $this->request->get('status', '')); $keyword = trim((string) $this->request->get('keyword', '')); $query = User::alias('u') ->whereRaw('LEFT(u.username, 6) = ?', ['guest_']) ->field("u.id,u.username,u.nickname,u.status,u.created_at,u.last_login_at, (SELECT COUNT(*) FROM conversations c WHERE c.user_id = u.id AND c.deleted_at IS NULL) AS conversation_count, (SELECT COUNT(*) FROM messages msg INNER JOIN conversations c2 ON msg.conversation_id = c2.id WHERE c2.user_id = u.id) AS message_count") ->order('u.last_login_at', 'desc') ->order('u.id', 'desc'); AdminScopeService::applyUserScope($query, $auth, 'u'); if (in_array($status, ['active', 'disabled'], true)) { $query->where('u.status', $status); } if ($keyword !== '') { $query->where('u.username', 'like', '%' . $keyword . '%'); } $total = (clone $query)->count(); $list = $query->page($page, $limit)->select(); return $this->success(['list' => $list, 'total' => $total]); } public function updateGuestStatus($id) { $auth = $this->authUser(); AdminScopeService::requireAny($auth, ['btn:guest:status']); $guest = User::find((int) $id); if (!$guest || !str_starts_with($guest->username, 'guest_')) { return $this->error('访客不存在', 404); } if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) { return $this->error('无权操作该访客', 403); } $status = trim((string) $this->request->put('status', '')); if (!in_array($status, ['active', 'disabled'], true)) { return $this->error('访客状态无效', 422); } $guest->save(['status' => $status]); return $this->success(null, $status === 'active' ? '访客已启用' : '访客已禁用'); } public function deleteGuest($id) { $auth = $this->authUser(); AdminScopeService::requireAny($auth, ['btn:guest:delete']); $guest = User::find((int) $id); if (!$guest || !str_starts_with($guest->username, 'guest_')) { return $this->error('访客不存在', 404); } if (!AdminScopeService::canViewUser($auth, (int) $guest->id)) { return $this->error('无权操作该访客', 403); } User::destroy((int) $guest->id); return $this->success(null, '访客记录已删除'); } public function conversations() { AdminScopeService::requireAny($this->authUser(), [ 'menu:conversations', 'btn:conv:view_all', 'btn:conv:view_subordinate', 'can_view_all_conversations', 'can_view_subordinate_conversations', ]); $page = max(1, (int) $this->request->get('page', 1)); $limit = min(50, max(1, (int) $this->request->get('limit', 20))); $userId = $this->request->get('user_id'); $keyword = trim((string) $this->request->get('keyword', '')); $departmentId = (int) $this->request->get('department_id', 0); $query = ConversationModel::alias('c') ->join('users u', 'c.user_id = u.id') ->leftJoin('ai_models m', 'c.model_id = m.id') ->leftJoin('departments d', 'u.department_id = d.id') ->whereNull('c.deleted_at') ->field('c.*,u.username,u.email,d.name as department_name,m.name as model_name') ->order('c.updated_at', 'desc'); AdminScopeService::applyConversationScope($query, $this->authUser(), 'c'); if ($userId) { $query->where('c.user_id', $userId); } if ($departmentId > 0) { $deptIds = DepartmentService::descendantIds($departmentId); $query->whereIn('u.department_id', $deptIds); } if ($keyword !== '') { $query->where(function ($q) use ($keyword) { $q->whereLike('c.title', "%{$keyword}%") ->whereOr('u.username', 'like', "%{$keyword}%") ->whereOr('u.email', 'like', "%{$keyword}%"); }); } $total = (clone $query)->count(); $list = $query->page($page, $limit)->select(); return $this->success(['list' => $list, 'total' => $total]); } public function conversationDetail($id) { AdminScopeService::requireAny($this->authUser(), [ 'menu:conversations', 'btn:conv:view_all', 'btn:conv:view_subordinate', 'can_view_all_conversations', 'can_view_subordinate_conversations', ]); $conversation = ConversationModel::alias('c') ->join('users u', 'c.user_id = u.id') ->leftJoin('ai_models m', 'c.model_id = m.id') ->leftJoin('departments d', 'u.department_id = d.id') ->where('c.id', $id) ->whereNull('c.deleted_at') ->field('c.*,u.username,u.email,d.name as department_name,m.name as model_name') ->find(); if (!$conversation) { return $this->error('会话不存在', 404); } if (!AdminScopeService::canViewConversation($this->authUser(), (int) $conversation->user_id)) { return $this->error('无权查看该会话', 403); } return $this->success([ 'conversation' => $conversation->toArray(), 'messages' => $this->buildMessageList((int) $id), ]); } private function buildMessageList(int $conversationId): array { Message::where('conversation_id', $conversationId) ->where('role', 'assistant') ->where('content', '') ->delete(); $rows = Message::where('conversation_id', $conversationId) ->field('id,role,content,content_type,attachments,created_at') ->order('created_at', 'asc') ->select(); $list = []; foreach ($rows as $item) { if ($item->role === 'assistant' && trim((string) $item->content) === '') { $attachments = is_string($item->attachments) ? json_decode($item->attachments, true) : ($item->attachments ?? []); if (empty($attachments)) { continue; } } $attachments = $item->attachments; if (is_string($attachments)) { $attachments = json_decode($attachments, true) ?: []; } $list[] = [ 'id' => $item->id, 'role' => $item->role, 'content' => $item->content, 'content_type' => $item->content_type, 'attachments' => $attachments ?: [], 'created_at' => $item->created_at, ]; } return $list; } public function stats() { return $this->success([ 'users' => User::count(), 'conversations' => ConversationModel::whereNull('deleted_at')->count(), 'messages' => Message::count(), 'today_messages' => (int) UserDailyStat::where('stat_date', date('Y-m-d'))->sum('message_count'), ]); } public function roles() { AdminScopeService::requireAny($this->authUser(), ['menu:roles', 'can_manage_roles', 'menu:users', 'can_manage_users']); $roles = Role::order('sort_order')->order('id')->select(); return $this->success($roles); } public function createRole() { AdminScopeService::requireAny($this->authUser(), ['btn:role:create', 'can_manage_roles']); $input = $this->request->post(); $name = trim($input['name'] ?? ''); $slug = trim($input['slug'] ?? ''); if ($name === '') { return $this->error('请填写角色名称'); } if ($slug === '') { $slug = 'role_' . uniqid(); } if (Role::where('slug', $slug)->find()) { return $this->error('角色标识已存在'); } $permissions = PermissionCatalog::normalize($input['permissions'] ?? []); $role = Role::create([ 'name' => $name, 'slug' => $slug, 'permissions' => $permissions, 'sort_order' => (int) ($input['sort_order'] ?? 0), ]); return $this->success(['id' => $role->id], '创建成功'); } public function updateRole($id) { AdminScopeService::requireAny($this->authUser(), ['btn:role:edit', 'can_manage_roles']); $role = Role::find((int) $id); if (!$role) { return $this->error('角色不存在', 404); } $input = $this->request->put(); $data = []; foreach (['name', 'slug', 'sort_order'] as $field) { if (isset($input[$field])) { $data[$field] = $input[$field]; } } if (isset($input['permissions'])) { $data['permissions'] = PermissionCatalog::normalize($input['permissions']); } if (isset($data['slug']) && Role::where('slug', $data['slug'])->where('id', '<>', $id)->find()) { return $this->error('角色标识已存在'); } if (empty($data)) { return $this->error('无更新内容'); } $role->save($data); return $this->success(null, '更新成功'); } public function deleteRole($id) { AdminScopeService::requireAny($this->authUser(), ['btn:role:delete', 'can_manage_roles']); $roleId = (int) $id; if (Role::where('slug', 'super_admin')->value('id') == $roleId) { return $this->error('超级管理员角色不可删除'); } if (User::where('role_id', $roleId)->count() > 0) { return $this->error('该角色下仍有用户,无法删除'); } Role::destroy($roleId); return $this->success(null, '删除成功'); } public function departments() { AdminScopeService::requireAny($this->authUser(), ['menu:departments', 'can_manage_departments']); $tree = DepartmentService::treeOptions(); $flat = Department::order('sort_order')->order('id')->select(); return $this->success(['tree' => $tree, 'list' => $flat]); } public function departmentOptions() { $options = DepartmentService::treeOptions(); return $this->success($options); } public function invitations() { AdminScopeService::requireAny($this->authUser(), [ 'menu:invitations', 'btn:invitation:create', 'btn:invitation:revoke', 'can_manage_users', ]); $status = trim((string) $this->request->get('status', '')); $query = InvitationCode::alias('i') ->leftJoin('departments d', 'i.department_id = d.id') ->leftJoin('users creator', 'i.created_by = creator.id') ->leftJoin('users used', 'i.used_by = used.id') ->field('i.*,d.name as department_name,creator.username as creator_name,used.username as used_by_name') ->order('i.created_at', 'desc') ->order('i.id', 'desc'); if (in_array($status, ['active', 'used', 'revoked'], true)) { $query->where('i.status', $status); } return $this->success($query->limit(500)->select()); } public function createInvitation() { $user = $this->authUser(); AdminScopeService::requireAny($user, [ 'btn:invitation:create', 'menu:invitations', 'can_manage_users', ]); $input = $this->request->post(); $departmentId = $input['department_id'] ?? null; if ($departmentId !== null && $departmentId !== '') { $departmentId = (int) $departmentId; if ($departmentId <= 0 || !Department::find($departmentId)) { return $this->error('所选部门不存在', 422); } } else { $departmentId = null; } $expiresAt = trim((string) ($input['expires_at'] ?? '')); if ($expiresAt !== '') { $expiresAt = str_replace('T', ' ', $expiresAt); $expiresTimestamp = strtotime($expiresAt); if (!$expiresTimestamp || $expiresTimestamp <= time()) { return $this->error('过期时间必须晚于当前时间', 422); } $expiresAt = date('Y-m-d H:i:s', $expiresTimestamp); } else { $expiresAt = null; } do { $code = 'INV-' . strtoupper(bin2hex(random_bytes(5))); } while (InvitationCode::where('code', $code)->find()); $invitation = InvitationCode::create([ 'code' => $code, 'department_id' => $departmentId, 'created_by' => (int) $user['id'], 'status' => 'active', 'expires_at' => $expiresAt, ]); return $this->success([ 'id' => (int) $invitation->id, 'code' => $invitation->code, 'department_id' => $departmentId, 'department_name' => $departmentId ? Department::where('id', $departmentId)->value('name') : null, 'status' => 'active', 'expires_at' => $expiresAt, 'created_at' => $invitation->created_at, ], '邀请码已生成'); } public function revokeInvitation($id) { AdminScopeService::requireAny($this->authUser(), [ 'btn:invitation:revoke', 'menu:invitations', 'can_manage_users', ]); $invitation = InvitationCode::find((int) $id); if (!$invitation) { return $this->error('邀请码不存在', 404); } if ($invitation->status === 'used') { return $this->error('已使用的邀请码不能作废', 422); } if ($invitation->status === 'revoked') { return $this->success(null, '邀请码已作废'); } $invitation->save(['status' => 'revoked']); return $this->success(null, '邀请码已作废'); } public function createDepartment() { AdminScopeService::requireAny($this->authUser(), ['btn:dept:create', 'can_manage_departments']); $input = $this->request->post(); $name = trim($input['name'] ?? ''); if ($name === '') { return $this->error('请填写部门名称'); } $parentId = $input['parent_id'] ?? null; if ($parentId !== null && $parentId !== '') { $parentId = (int) $parentId; if (!Department::find($parentId)) { return $this->error('上级部门不存在'); } } else { $parentId = null; } $dept = Department::create([ 'name' => $name, 'parent_id' => $parentId, 'sort_order' => (int) ($input['sort_order'] ?? 0), ]); return $this->success(['id' => $dept->id], '创建成功'); } public function updateDepartment($id) { AdminScopeService::requireAny($this->authUser(), ['btn:dept:edit', 'can_manage_departments']); $dept = Department::find((int) $id); if (!$dept) { return $this->error('部门不存在', 404); } $input = $this->request->put(); $data = []; foreach (['name', 'sort_order'] as $field) { if (isset($input[$field])) { $data[$field] = $input[$field]; } } if (array_key_exists('parent_id', $input)) { $parentId = $input['parent_id']; if ($parentId === '' || $parentId === null) { $data['parent_id'] = null; } else { $parentId = (int) $parentId; if ($parentId === (int) $id) { return $this->error('上级部门不能是自己'); } if ($parentId > 0) { $descendants = DepartmentService::descendantIds((int) $id); if (in_array($parentId, $descendants, true)) { return $this->error('上级部门不能是当前部门的下级'); } if (!Department::find($parentId)) { return $this->error('上级部门不存在'); } } $data['parent_id'] = $parentId > 0 ? $parentId : null; } } if (empty($data)) { return $this->error('无更新内容'); } $dept->save($data); return $this->success(null, '更新成功'); } public function deleteDepartment($id) { AdminScopeService::requireAny($this->authUser(), ['btn:dept:delete', 'can_manage_departments']); $deptId = (int) $id; if (Department::where('parent_id', $deptId)->count() > 0) { return $this->error('请先删除或移走下级部门'); } if (User::where('department_id', $deptId)->count() > 0) { return $this->error('该部门下仍有用户,无法删除'); } Department::destroy($deptId); return $this->success(null, '删除成功'); } public function settings() { AdminScopeService::requireAny($this->authUser(), ['menu:settings', 'can_manage_settings']); $rows = SystemSetting::select(); $settings = []; foreach ($rows as $row) { $decoded = json_decode($row->setting_value, true); $settings[$row->setting_key] = [ 'value' => json_last_error() === JSON_ERROR_NONE ? $decoded : $row->setting_value, 'description' => $row->description, ]; } return $this->success($settings); } public function updateSettings() { AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']); $input = $this->request->put(); foreach ($input as $key => $value) { if ($key === 'voice_persona') { if (!is_array($value)) { return $this->error('AI 客服人物配置格式无效', 422); } $value = CosyVoiceService::normalizePersona($value); } SettingsService::set($key, $value); } CosyVoiceService::clearFailure(); return $this->success(null, '设置已更新'); } public function uploadVoiceReference() { AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']); $file = $this->request->file('file'); if (!$file) { return $this->error('请选择 WAV 参考音频', 422); } $originalName = basename((string) $file->getOriginalName()); $extension = strtolower($file->extension() ?: pathinfo($originalName, PATHINFO_EXTENSION)); if ($extension !== 'wav') { return $this->error('音色样本只支持 WAV 文件', 422); } if ((int) $file->getSize() > 15 * 1024 * 1024) { return $this->error('音色样本不能超过 15MB', 422); } $header = @file_get_contents($file->getPathname(), false, null, 0, 12); if (!is_string($header) || strlen($header) < 12 || substr($header, 0, 4) !== 'RIFF' || substr($header, 8, 4) !== 'WAVE') { return $this->error('文件不是有效的 WAV 音频', 422); } $targetDir = root_path() . 'storage' . DIRECTORY_SEPARATOR . 'cosyvoice'; if (!is_dir($targetDir) && !mkdir($targetDir, 0755, true) && !is_dir($targetDir)) { return $this->error('无法创建音色样本目录', 500); } $storedName = 'voice-' . date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.wav'; $moved = $file->move($targetDir, $storedName); if (!$moved) { return $this->error('音色样本保存失败', 500); } $path = $targetDir . DIRECTORY_SEPARATOR . $storedName; $persona = CosyVoiceService::getPersona(); $persona['prompt_wav'] = $path; $persona['prompt_wav_name'] = $originalName; $persona = CosyVoiceService::normalizePersona($persona, $persona); SettingsService::set('voice_persona', $persona); CosyVoiceService::clearFailure($persona); return $this->success([ 'name' => $persona['prompt_wav_name'], 'path' => $persona['prompt_wav'], ], '音色样本上传成功'); } public function previewVoicePersona() { AdminScopeService::requireAny($this->authUser(), ['btn:settings:save', 'can_manage_settings']); $text = trim((string) ($this->request->post('text') ?: '您好,我是您的 AI 客服,很高兴为您服务。')); $text = mb_substr($text, 0, 160); try { CosyVoiceService::clearFailure(); $speech = CosyVoiceService::speech($text); } catch (\Throwable $error) { return $this->error($error->getMessage(), 502); } return response($speech['audio'], 200, [ 'Content-Type' => $speech['content_type'], 'Content-Length' => (string) strlen($speech['audio']), 'Cache-Control' => 'no-store', 'X-Content-Type-Options' => 'nosniff', 'X-TTS-Provider' => 'cosyvoice', ]); } public function models() { AdminScopeService::requireAny($this->authUser(), ['menu:models', 'can_manage_models']); $this->ensureAiModelExtraConfigColumn(); $rows = AiModel::field('id,name,provider,model_id,api_base_url,api_key,max_tokens,temperature,is_default,enabled,support_context,support_image,frequency_penalty,presence_penalty,extra_config,sort_order') ->order('sort_order') ->select(); $list = []; foreach ($rows as $row) { $item = $row->toArray(); $item['has_api_key'] = !empty($item['api_key']); $item['api_key_hint'] = $this->maskApiKey($item['api_key'] ?? ''); unset($item['api_key']); if (isset($item['extra_config']) && is_string($item['extra_config'])) { $item['extra_config'] = json_decode($item['extra_config'], true) ?: null; } $list[] = $item; } return $this->success($list); } public function testModel($id = null) { AdminScopeService::requireAny($this->authUser(), ['btn:model:test', 'can_manage_models']); $input = $this->request->post(); $config = []; $provider = $input['provider'] ?? 'openai'; if ($id) { $model = AiModel::find((int) $id); if (!$model) { return $this->error('模型不存在', 404); } $provider = $input['provider'] ?? $model->provider ?? 'openai'; $config = [ 'api_base_url' => $input['api_base_url'] ?? $model->api_base_url, 'model_id' => $input['model_id'] ?? $model->model_id, 'api_key' => !empty($input['api_key']) ? $input['api_key'] : $model->api_key, 'temperature' => $input['temperature'] ?? $model->temperature, ]; } else { $config = [ 'api_base_url' => $input['api_base_url'] ?? '', 'model_id' => $input['model_id'] ?? '', 'api_key' => $input['api_key'] ?? '', 'temperature' => $input['temperature'] ?? 0.7, ]; } try { if ($provider === 'dify') { $result = DifyService::testConnection($config); } elseif ($provider === 'comfy') { $result = ComfyUIService::testConnection($config); } else { $result = OpenAIService::testConnection($config); } return $this->success($result, '测试成功'); } catch (\InvalidArgumentException $e) { return $this->error($e->getMessage()); } catch (\Throwable $e) { return $this->error($e->getMessage(), 502); } } public function createModel() { AdminScopeService::requireAny($this->authUser(), ['btn:model:create', 'can_manage_models']); $this->ensureAiModelExtraConfigColumn(); $input = $this->request->post(); $extraConfig = $this->normalizeExtraConfig($input['extra_config'] ?? null, $input['provider'] ?? 'openai'); if (is_string($extraConfig)) { return $this->error($extraConfig); } $model = AiModel::create([ 'name' => $input['name'], 'provider' => in_array($input['provider'] ?? 'openai', ['openai', 'dify', 'comfy'], true) ? $input['provider'] : 'openai', 'model_id' => $input['model_id'] ?? '', 'api_base_url' => $input['api_base_url'] ?? 'https://api.openai.com/v1', 'api_key' => $input['api_key'], 'max_tokens' => $input['max_tokens'] ?? 4096, 'temperature' => $input['temperature'] ?? 0.7, 'is_default' => (int) ($input['is_default'] ?? 0), 'enabled' => (int) ($input['enabled'] ?? 1), 'support_context' => (int) ($input['support_context'] ?? 1), 'support_image' => (int) ($input['support_image'] ?? 1), 'frequency_penalty' => $input['frequency_penalty'] ?? 0, 'presence_penalty' => $input['presence_penalty'] ?? 0, 'extra_config' => $extraConfig, 'sort_order' => $input['sort_order'] ?? 0, ]); if (!empty($input['is_default'])) { AiModel::where('id', '<>', $model->id)->update(['is_default' => 0]); } return $this->success(['id' => $model->id], '创建成功'); } public function updateModel($id) { AdminScopeService::requireAny($this->authUser(), ['btn:model:edit', 'can_manage_models']); $this->ensureAiModelExtraConfigColumn(); $input = $this->request->put(); $allowed = ['name', 'provider', 'model_id', 'api_base_url', 'api_key', 'max_tokens', 'temperature', 'is_default', 'enabled', 'support_context', 'support_image', 'frequency_penalty', 'presence_penalty', 'sort_order']; $data = []; foreach ($allowed as $field) { if (array_key_exists($field, $input)) { $data[$field] = $input[$field]; } } if (array_key_exists('api_key', $data) && trim((string) $data['api_key']) === '') { unset($data['api_key']); } if (isset($data['provider']) && !in_array($data['provider'], ['openai', 'dify', 'comfy'], true)) { $data['provider'] = 'openai'; } if (array_key_exists('extra_config', $input)) { $provider = $data['provider'] ?? (AiModel::where('id', $id)->value('provider') ?: 'openai'); $extraConfig = $this->normalizeExtraConfig($input['extra_config'], $provider); if (is_string($extraConfig)) { return $this->error($extraConfig); } $data['extra_config'] = $extraConfig === null ? null : json_encode($extraConfig, JSON_UNESCAPED_UNICODE); } if (empty($data)) { return $this->error('无更新内容'); } AiModel::where('id', $id)->update($data); if (!empty($input['is_default'])) { AiModel::where('id', '<>', $id)->update(['is_default' => 0]); } return $this->success(null, '更新成功'); } /** * @return array|null|string 成功返回数组/null;失败返回错误文案 */ private function normalizeExtraConfig(mixed $raw, string $provider): array|null|string { if ($raw === null || $raw === '' || $raw === []) { return null; } if (is_string($raw)) { $decoded = json_decode($raw, true); if (!is_array($decoded)) { return 'extra_config 不是有效 JSON'; } $raw = $decoded; } if (!is_array($raw)) { return 'extra_config 格式无效'; } $config = []; foreach (['workflow', 'img2img_workflow', 'inpaint_workflow', 'outpaint_workflow'] as $workflowKey) { if (!isset($raw[$workflowKey])) { continue; } if (is_string($raw[$workflowKey])) { $wf = json_decode($raw[$workflowKey], true); if (!is_array($wf)) { return $workflowKey . ' JSON 无效'; } $raw[$workflowKey] = $wf; } if (is_array($raw[$workflowKey]) && $raw[$workflowKey] !== []) { $config[$workflowKey] = $raw[$workflowKey]; } } foreach ([ 'prompt_node', 'seed_node', 'aspect_ratio', 'system_prompt', 'edit_system_prompt', 'img2img_prompt_node', 'img2img_seed_node', 'img2img_image_node', 'inpaint_prompt_node', 'inpaint_seed_node', 'inpaint_image_node', 'inpaint_mask_node', 'tts_model', 'tts_voice', 'tts_instructions', ] as $key) { if (!array_key_exists($key, $raw)) { continue; } $val = is_string($raw[$key]) ? trim($raw[$key]) : $raw[$key]; if ($val === null || $val === '') { continue; } $config[$key] = (string) $val; } foreach (['img2img_denoise', 'inpaint_denoise'] as $key) { if (!array_key_exists($key, $raw) || $raw[$key] === '' || $raw[$key] === null) { continue; } if (!is_numeric($raw[$key])) { return $key . ' 必须是 0 到 1 之间的数字'; } $config[$key] = max(0.01, min(1.0, (float) $raw[$key])); } if (array_key_exists('refine_prompt', $raw)) { $config['refine_prompt'] = filter_var($raw['refine_prompt'], FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); if ($config['refine_prompt'] === null) { $config['refine_prompt'] = (bool) $raw['refine_prompt']; } } if ($provider === 'comfy' && $config !== []) { $err = ComfyUIService::validateWorkflowConfig($config); if ($err) { return $err; } } return $config === [] ? null : $config; } private function ensureAiModelExtraConfigColumn(): void { static $ensured = false; if ($ensured) { return; } $ensured = true; try { $cols = \think\facade\Db::query("SHOW COLUMNS FROM `ai_models` LIKE 'extra_config'"); if (!empty($cols)) { return; } \think\facade\Db::execute( "ALTER TABLE `ai_models` ADD COLUMN `extra_config` JSON NULL COMMENT 'ComfyUI工作流等扩展配置' AFTER `presence_penalty`" ); } catch (\Throwable $e) { // 并发创建或无权限时忽略,后续读写会按无该字段降级 } } public function deleteModel($id) { AdminScopeService::requireAny($this->authUser(), ['btn:model:delete', 'can_manage_models']); AiModel::destroy($id); return $this->success(null, '删除成功'); } public function memberships() { AdminScopeService::requireAny($this->authUser(), ['menu:memberships', 'can_manage_memberships', 'menu:users', 'can_manage_users']); $levels = MembershipLevel::order('sort_order')->select(); return $this->success($levels); } public function createMembership() { AdminScopeService::requireAny($this->authUser(), ['btn:membership:create', 'btn:membership:edit', 'can_manage_memberships']); $input = $this->request->post(); $name = trim($input['name'] ?? ''); $slug = trim($input['slug'] ?? ''); if ($name === '') { return $this->error('请填写名称'); } if ($slug === '') { $slug = 'level_' . substr(uniqid(), -6); } if (MembershipLevel::where('slug', $slug)->find()) { return $this->error('标识 slug 已存在'); } $level = MembershipLevel::create([ 'name' => $name, 'slug' => $slug, 'max_conversations' => (int) ($input['max_conversations'] ?? 20), 'max_messages_per_day' => (int) ($input['max_messages_per_day'] ?? 50), 'max_upload_size_mb' => (int) ($input['max_upload_size_mb'] ?? 5), 'permissions' => $input['permissions'] ?? [ 'can_upload_image' => false, 'can_upload_video' => false, 'can_upload_file' => false, 'can_use_voice' => false, ], 'sort_order' => (int) ($input['sort_order'] ?? 0), ]); return $this->success(['id' => $level->id], '创建成功'); } public function updateMembership($id) { AdminScopeService::requireAny($this->authUser(), ['btn:membership:edit', 'can_manage_memberships']); $level = MembershipLevel::find((int) $id); if (!$level) { return $this->error('会员等级不存在', 404); } $input = $this->request->put(); $data = []; foreach (['name', 'max_conversations', 'max_messages_per_day', 'max_upload_size_mb', 'sort_order'] as $field) { if (isset($input[$field])) { $data[$field] = $input[$field]; } } if (isset($input['slug']) && trim((string) $input['slug']) !== '') { $slug = trim((string) $input['slug']); if (MembershipLevel::where('slug', $slug)->where('id', '<>', $id)->find()) { return $this->error('标识 slug 已存在'); } $data['slug'] = $slug; } if (isset($input['permissions'])) { $data['permissions'] = $input['permissions']; } if (isset($input['allowed_models'])) { $data['allowed_models'] = $input['allowed_models']; } if (empty($data)) { return $this->error('无更新内容'); } $level->save($data); return $this->success(null, '更新成功'); } public function deleteMembership($id) { AdminScopeService::requireAny($this->authUser(), ['btn:membership:delete', 'can_manage_memberships']); $levelId = (int) $id; $level = MembershipLevel::find($levelId); if (!$level) { return $this->error('会员等级不存在', 404); } if (in_array($level->slug, ['free', 'admin'], true) || $levelId === 1) { return $this->error('系统默认会员等级不可删除'); } $userCount = User::where('membership_level_id', $levelId)->count(); if ($userCount > 0) { return $this->error("该等级下还有 {$userCount} 个用户,请先调整用户会员后再删除"); } MembershipLevel::destroy($levelId); return $this->success(null, '删除成功'); } private function maskApiKey(?string $key): ?string { $key = trim((string) $key); if ($key === '') { return null; } $len = strlen($key); if ($len <= 8) { return str_repeat('*', $len); } return substr($key, 0, 4) . str_repeat('*', min($len - 8, 12)) . substr($key, -4); } }