This commit is contained in:
Your Name
2026-07-22 10:18:59 +08:00
parent 2530ddada6
commit 0fb03d0bca
618 changed files with 19445 additions and 3 deletions
+1
View File
@@ -0,0 +1 @@
deny from all
+22
View File
@@ -0,0 +1,22 @@
<?php
declare (strict_types = 1);
namespace app;
use think\Service;
/**
* 应用服务类
*/
class AppService extends Service
{
public function register()
{
// 服务注册
}
public function boot()
{
// 服务启动
}
}
+94
View File
@@ -0,0 +1,94 @@
<?php
declare (strict_types = 1);
namespace app;
use think\App;
use think\exception\ValidateException;
use think\Validate;
/**
* 控制器基础类
*/
abstract class BaseController
{
/**
* Request实例
* @var \think\Request
*/
protected $request;
/**
* 应用实例
* @var \think\App
*/
protected $app;
/**
* 是否批量验证
* @var bool
*/
protected $batchValidate = false;
/**
* 控制器中间件
* @var array
*/
protected $middleware = [];
/**
* 构造方法
* @access public
* @param App $app 应用对象
*/
public function __construct(App $app)
{
$this->app = $app;
$this->request = $this->app->request;
// 控制器初始化
$this->initialize();
}
// 初始化
protected function initialize()
{}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @return array|string|true
* @throws ValidateException
*/
protected function validate(array $data, string|array $validate, array $message = [], bool $batch = false)
{
if (is_array($validate)) {
$v = new Validate();
$v->rule($validate);
} else {
if (strpos($validate, '.')) {
// 支持场景
[$validate, $scene] = explode('.', $validate);
}
$class = false !== strpos($validate, '\\') ? $validate : $this->app->parseClass('validate', $validate);
$v = new $class();
if (!empty($scene)) {
$v->scene($scene);
}
}
$v->message($message);
// 是否批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
return $v->failException(true)->check($data);
}
}
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace app;
use think\db\exception\DataNotFoundException;
use think\db\exception\ModelNotFoundException;
use think\exception\Handle;
use think\exception\HttpException;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Response;
use Throwable;
/**
* 应用异常处理类
*/
class ExceptionHandle extends Handle
{
/**
* 不需要记录信息(日志)的异常类列表
* @var array
*/
protected $ignoreReport = [
HttpException::class,
HttpResponseException::class,
ModelNotFoundException::class,
DataNotFoundException::class,
ValidateException::class,
];
/**
* 记录异常信息(包括日志或者其它方式记录)
*
* @access public
* @param Throwable $exception
* @return void
*/
public function report(Throwable $exception): void
{
// 使用内置的方式记录异常日志
parent::report($exception);
}
/**
* Render an exception into an HTTP response.
*
* @access public
* @param \think\Request $request
* @param Throwable $e
* @return Response
*/
public function render($request, Throwable $e): Response
{
// 添加自定义异常处理机制
// 其他错误交给系统处理
return parent::render($request, $e);
}
}
+8
View File
@@ -0,0 +1,8 @@
<?php
namespace app;
// 应用请求对象类
class Request extends \think\Request
{
}
+2
View File
@@ -0,0 +1,2 @@
<?php
// 应用公共文件
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace app\controller;
use app\BaseController;
class Index extends BaseController
{
public function index()
{
return '<style>*{ padding: 0; margin: 0; }</style><iframe src="https://www.thinkphp.cn/welcome?version=' . \think\facade\App::version() . '" width="100%" height="100%" frameborder="0" scrolling="auto"></iframe>';
}
public function hello($name = 'ThinkPHP8')
{
return 'hello,' . $name;
}
}
+197
View File
@@ -0,0 +1,197 @@
<?php
namespace app\controller\api;
use app\model\AiModel;
use app\model\Conversation as ConversationModel;
use app\model\MembershipLevel;
use app\model\Message;
use app\model\SystemSetting;
use app\model\User;
use app\model\UserDailyStat;
use app\service\SettingsService;
class Admin extends BaseApi
{
public function users()
{
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
$query = User::alias('u')
->leftJoin('membership_levels ml', 'u.membership_level_id = ml.id')
->field('u.id,u.username,u.email,u.nickname,u.role,u.status,u.membership_level_id,u.created_at,u.last_login_at,ml.name as membership_name')
->order('u.id', 'desc');
$total = (clone $query)->count();
$list = $query->page($page, $limit)->select();
return $this->success(['list' => $list, 'total' => $total]);
}
public function updateUser($id)
{
$input = $this->request->put();
$allowed = ['nickname', 'role', 'status', 'membership_level_id'];
$data = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $input)) {
$data[$field] = $input[$field];
}
}
if (empty($data)) {
return $this->error('无更新内容');
}
User::where('id', $id)->update($data);
return $this->success(null, '更新成功');
}
public function 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');
$query = ConversationModel::alias('c')
->join('users u', 'c.user_id = u.id')
->whereNull('c.deleted_at')
->field('c.*,u.username,u.email')
->order('c.updated_at', 'desc');
if ($userId) {
$query->where('c.user_id', $userId);
}
return $this->success($query->page($page, $limit)->select());
}
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 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()
{
$input = $this->request->put();
foreach ($input as $key => $value) {
SettingsService::set($key, $value);
}
return $this->success(null, '设置已更新');
}
public function models()
{
$list = AiModel::field('id,name,model_id,api_base_url,max_tokens,temperature,is_default,enabled,sort_order')
->order('sort_order')
->select();
return $this->success($list);
}
public function createModel()
{
$input = $this->request->post();
$model = AiModel::create([
'name' => $input['name'],
'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),
'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)
{
$input = $this->request->put();
$allowed = ['name', 'model_id', 'api_base_url', 'api_key', 'max_tokens', 'temperature', 'is_default', 'enabled', 'sort_order'];
$data = [];
foreach ($allowed as $field) {
if (array_key_exists($field, $input)) {
$data[$field] = $input[$field];
}
}
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, '更新成功');
}
public function deleteModel($id)
{
AiModel::destroy($id);
return $this->success(null, '删除成功');
}
public function memberships()
{
$levels = MembershipLevel::order('sort_order')->select();
return $this->success($levels);
}
public function updateMembership($id)
{
$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['permissions'])) {
$data['permissions'] = $input['permissions'];
}
if (isset($input['allowed_models'])) {
$data['allowed_models'] = $input['allowed_models'];
}
if (empty($data)) {
return $this->error('无更新内容');
}
MembershipLevel::where('id', $id)->update($data);
return $this->success(null, '更新成功');
}
}
+121
View File
@@ -0,0 +1,121 @@
<?php
namespace app\controller\api;
use app\model\User;
use app\service\JwtService;
use app\service\SettingsService;
class Auth extends BaseApi
{
public function register()
{
$allow = SettingsService::get('allow_register', true);
if ($allow !== true && $allow !== 'true') {
return $this->error('当前不允许注册');
}
$input = $this->request->post();
$username = trim($input['username'] ?? '');
$email = trim($input['email'] ?? '');
$password = $input['password'] ?? '';
if (strlen($username) < 3 || strlen($username) > 50) {
return $this->error('用户名长度需 3-50 个字符');
}
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('用户名或邮箱已存在');
}
$user = User::create([
'username' => $username,
'email' => $email,
'password_hash' => password_hash($password, PASSWORD_BCRYPT),
'nickname' => $username,
'membership_level_id' => 1,
]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->id),
], '注册成功');
}
public function login()
{
$input = $this->request->post();
$account = trim($input['account'] ?? '');
$password = $input['password'] ?? '';
if (!$account || !$password) {
return $this->error('请输入账号和密码');
}
$user = User::where(function ($query) use ($account) {
$query->where('username', $account)->whereOr('email', $account);
})->where('status', 'active')->find();
if (!$user || !password_verify($password, $user->password_hash)) {
return $this->error('账号或密码错误', 401);
}
$user->save(['last_login_at' => date('Y-m-d H:i:s')]);
$token = JwtService::generateToken(['user_id' => $user->id]);
return $this->success([
'token' => $token,
'user' => $this->formatUser($user->id),
], '登录成功');
}
public function me()
{
return $this->success($this->authUser());
}
public function updateProfile()
{
$user = $this->authUser();
$nickname = trim($this->request->put('nickname', ''));
if ($nickname) {
User::where('id', $user['id'])->update(['nickname' => $nickname]);
}
return $this->success($this->formatUser($user['id']));
}
public function logout()
{
return $this->success(null, '已退出');
}
private function formatUser(int $userId): array
{
$user = User::with(['membership'])->find($userId);
$level = $user->membership;
return [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'role' => $user->role,
'membership_name' => $level?->name,
'membership_slug' => $level?->slug,
'max_conversations' => $level?->max_conversations,
'max_messages_per_day' => $level?->max_messages_per_day,
'membership_permissions' => $level?->permissions ?? [],
];
}
}
@@ -0,0 +1,32 @@
<?php
namespace app\controller\api;
use app\BaseController;
use think\response\Json;
class BaseApi extends BaseController
{
protected function success(mixed $data = null, string $message = 'success', int $httpCode = 200): Json
{
return json([
'code' => 0,
'message' => $message,
'data' => $data,
], $httpCode);
}
protected function error(string $message, int $httpCode = 400, int $code = 1): Json
{
return json([
'code' => $code,
'message' => $message,
'data' => null,
], $httpCode);
}
protected function authUser(): array
{
return $this->request->authUser ?? [];
}
}
+173
View File
@@ -0,0 +1,173 @@
<?php
namespace app\controller\api;
use app\model\Conversation as ConversationModel;
use app\model\Message;
use app\service\OpenAIService;
use app\service\PermissionService;
use app\service\SettingsService;
use think\exception\HttpResponseException;
class Chat extends BaseApi
{
public function completions()
{
$user = $this->authUser();
PermissionService::checkDailyLimit($user);
$input = $this->request->post();
$conversationId = (int) ($input['conversation_id'] ?? 0);
$content = trim($input['content'] ?? '');
$attachments = $input['attachments'] ?? [];
$stream = ($input['stream'] ?? true) !== false;
if (!$conversationId) {
return $this->error('缺少 conversation_id');
}
if (!$content && empty($attachments)) {
return $this->error('消息内容不能为空');
}
$conversation = ConversationModel::where('id', $conversationId)
->where('user_id', $user['id'])
->whereNull('deleted_at')
->find();
if (!$conversation) {
return $this->error('会话不存在', 404);
}
$contentType = SettingsService::isFeatureEnabled('markdown') ? 'markdown' : 'text';
if (!empty($attachments)) {
$contentType = 'mixed';
}
Message::create([
'conversation_id' => $conversationId,
'role' => 'user',
'content' => $content,
'content_type' => $contentType,
'attachments' => $attachments,
]);
PermissionService::incrementDailyCount((int) $user['id']);
$history = Message::where('conversation_id', $conversationId)
->field('role,content,attachments')
->order('created_at', 'asc')
->limit(50)
->select()
->toArray();
$apiMessages = $this->buildApiMessages($history);
$model = OpenAIService::getModel($conversation->model_id ? (int) $conversation->model_id : null);
if ((int) $conversation->message_count === 0 && $content) {
$conversation->save(['title' => mb_substr($content, 0, 30)]);
}
$conversation->inc('message_count')->update(['updated_at' => date('Y-m-d H:i:s')]);
if ($stream) {
$this->streamResponse($conversationId, $model, $apiMessages, $user);
}
return $this->syncResponse($conversationId, $model, $apiMessages, $user);
}
private function streamResponse(int $conversationId, $model, array $apiMessages, array $user): void
{
OpenAIService::sseHeaders();
$fullContent = '';
OpenAIService::streamChat($model, $apiMessages, function ($chunk) use (&$fullContent) {
$delta = $chunk['choices'][0]['delta']['content'] ?? '';
if ($delta) {
$fullContent .= $delta;
OpenAIService::sseEvent('message', ['content' => $delta]);
}
});
Message::create([
'conversation_id' => $conversationId,
'role' => 'assistant',
'content' => $fullContent,
'content_type' => 'markdown',
]);
ConversationModel::where('id', $conversationId)->inc('message_count')->update([
'updated_at' => date('Y-m-d H:i:s'),
]);
PermissionService::incrementDailyCount((int) $user['id']);
OpenAIService::sseEvent('done', ['content' => $fullContent]);
exit;
}
private function syncResponse(int $conversationId, $model, array $apiMessages, array $user)
{
$result = OpenAIService::chat($model, $apiMessages);
$content = $result['choices'][0]['message']['content'] ?? '';
$tokens = $result['usage']['total_tokens'] ?? 0;
Message::create([
'conversation_id' => $conversationId,
'role' => 'assistant',
'content' => $content,
'content_type' => 'markdown',
'tokens_used' => $tokens,
]);
ConversationModel::where('id', $conversationId)->inc('message_count')->update([
'updated_at' => date('Y-m-d H:i:s'),
]);
PermissionService::incrementDailyCount((int) $user['id']);
return $this->success(['content' => $content, 'tokens' => $tokens]);
}
private function buildApiMessages(array $history): array
{
$messages = [];
foreach ($history as $msg) {
$content = $msg['content'];
$attachments = $msg['attachments'] ?? [];
if (is_string($attachments)) {
$attachments = json_decode($attachments, true) ?: [];
}
if (!empty($attachments) && SettingsService::isFeatureEnabled('image')) {
$parts = [];
if ($content) {
$parts[] = ['type' => 'text', 'text' => $content];
}
foreach ($attachments as $att) {
if (($att['type'] ?? '') === 'image') {
$parts[] = [
'type' => 'image_url',
'image_url' => ['url' => $this->absoluteUrl($att['url'])],
];
}
}
$messages[] = ['role' => $msg['role'], 'content' => $parts];
} else {
$messages[] = ['role' => $msg['role'], 'content' => $content];
}
}
return $messages;
}
private function absoluteUrl(string $url): string
{
if (str_starts_with($url, 'http')) {
return $url;
}
$scheme = $this->request->scheme();
$host = $this->request->host();
return "{$scheme}://{$host}{$url}";
}
}
@@ -0,0 +1,144 @@
<?php
namespace app\controller\api;
use app\model\Conversation as ConversationModel;
use app\model\Message;
use app\service\PermissionService;
use think\exception\HttpResponseException;
class Conversation extends BaseApi
{
public function index()
{
$user = $this->authUser();
$page = max(1, (int) $this->request->get('page', 1));
$limit = min(50, max(1, (int) $this->request->get('limit', 20)));
$query = ConversationModel::alias('c')
->leftJoin('ai_models m', 'c.model_id = m.id')
->where('c.user_id', $user['id'])
->whereNull('c.deleted_at')
->field('c.*,m.name as model_name')
->order(['c.is_pinned' => 'desc', 'c.updated_at' => 'desc']);
$total = (clone $query)->count();
$list = $query->page($page, $limit)->select();
return $this->success([
'list' => $list,
'total' => $total,
'page' => $page,
'limit' => $limit,
]);
}
public function create()
{
$user = $this->authUser();
PermissionService::checkConversationLimit($user);
$input = $this->request->post();
$conversation = ConversationModel::create([
'user_id' => $user['id'],
'title' => trim($input['title'] ?? '新对话'),
'model_id' => $input['model_id'] ?? null,
]);
return $this->success($this->findConversation((int) $conversation->id, (int) $user['id']), '创建成功');
}
public function show($id)
{
$user = $this->authUser();
return $this->success($this->findConversation((int) $id, (int) $user['id']));
}
public function update($id)
{
$user = $this->authUser();
$conversation = ConversationModel::where('id', $id)
->where('user_id', $user['id'])
->whereNull('deleted_at')
->find();
if (!$conversation) {
return $this->error('会话不存在', 404);
}
$input = $this->request->put();
$data = [];
if (isset($input['title'])) {
$data['title'] = trim($input['title']);
}
if (isset($input['is_pinned'])) {
$data['is_pinned'] = (int) $input['is_pinned'];
}
if (array_key_exists('model_id', $input)) {
$data['model_id'] = $input['model_id'];
}
if (empty($data)) {
return $this->error('无更新内容');
}
$conversation->save($data);
return $this->success($this->findConversation((int) $id, (int) $user['id']));
}
public function delete($id)
{
$user = $this->authUser();
$conversation = ConversationModel::where('id', $id)
->where('user_id', $user['id'])
->whereNull('deleted_at')
->find();
if (!$conversation) {
return $this->error('会话不存在', 404);
}
$conversation->save(['deleted_at' => date('Y-m-d H:i:s')]);
return $this->success(null, '删除成功');
}
public function messages($id)
{
$user = $this->authUser();
$this->findConversation((int) $id, (int) $user['id']);
$messages = Message::where('conversation_id', $id)
->field('id,role,content,content_type,attachments,created_at')
->order('created_at', 'asc')
->select()
->each(function ($item) {
if (is_string($item->attachments)) {
$item->attachments = json_decode($item->attachments, true) ?: [];
}
return $item;
});
return $this->success($messages);
}
private function findConversation(int $id, int $userId): array
{
$conversation = ConversationModel::alias('c')
->leftJoin('ai_models m', 'c.model_id = m.id')
->where('c.id', $id)
->where('c.user_id', $userId)
->whereNull('c.deleted_at')
->field('c.*,m.name as model_name')
->find();
if (!$conversation) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '会话不存在',
'data' => null,
], 404));
}
return $conversation->toArray();
}
}
@@ -0,0 +1,34 @@
<?php
namespace app\controller\api;
use app\model\AiModel;
use app\service\SettingsService;
class Settings extends BaseApi
{
public function features()
{
return $this->success(SettingsService::getFeatures());
}
public function publicSettings()
{
$allow = SettingsService::get('allow_register', true);
return $this->success([
'site_name' => SettingsService::get('site_name', 'AI Chat'),
'allow_register' => $allow === true || $allow === 'true',
'features' => SettingsService::getFeatures(),
]);
}
public function models()
{
$list = AiModel::where('enabled', 1)
->field('id,name,model_id,is_default')
->order('sort_order,id')
->select();
return $this->success($list);
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace app\controller\api;
use app\model\UploadFile;
use app\service\PermissionService;
use think\exception\HttpResponseException;
class Upload extends BaseApi
{
public function upload()
{
$user = $this->authUser();
$file = $this->request->file('file');
if (!$file) {
return $this->error('请选择文件');
}
$mime = $file->getMime() ?: mime_content_type($file->getPathname());
$fileType = $this->detectFileType($mime);
if (!PermissionService::canUpload($user, $fileType)) {
return $this->error('您没有权限上传此类型文件或功能未开启', 403);
}
$maxSize = PermissionService::getMaxUploadSizeMb($user) * 1024 * 1024;
if ($file->getSize() > $maxSize) {
return $this->error('文件大小超出限制');
}
if (!$this->isAllowedMime($mime, $fileType)) {
return $this->error('不支持的文件类型');
}
$originalName = $file->getOriginalName();
$fileSize = $file->getSize();
$uploadPath = config('upload.path');
if (!is_dir($uploadPath)) {
mkdir($uploadPath, 0755, true);
}
$ext = $file->extension() ?: 'bin';
$subdir = date('Y/m/d');
$storedBase = uniqid() . '.' . $ext;
$storedName = $subdir . '/' . $storedBase;
$fullDir = $uploadPath . '/' . $subdir;
if (!is_dir($fullDir)) {
mkdir($fullDir, 0755, true);
}
$file->move($fullDir, $storedBase);
$record = UploadFile::create([
'user_id' => $user['id'],
'original_name' => $originalName,
'stored_name' => $storedBase,
'file_path' => $storedName,
'mime_type' => $mime,
'file_size' => $fileSize,
'file_type' => $fileType,
]);
return $this->success([
'id' => $record->id,
'url' => '/api/uploads/' . urlencode($storedBase),
'name' => $originalName,
'type' => $fileType,
'mime' => $mime,
'size' => $fileSize,
]);
}
public function serve($filename)
{
$filename = basename($filename);
$upload = UploadFile::where('stored_name', $filename)
->whereOr('file_path', 'like', '%/' . $filename)
->find();
if (!$upload) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
$path = config('upload.path') . '/' . $upload->file_path;
if (!is_file($path)) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '文件不存在',
'data' => null,
], 404));
}
return download($path, $upload->original_name, true)
->mimeType($upload->mime_type);
}
private function detectFileType(string $mime): string
{
if (str_starts_with($mime, 'image/')) {
return 'image';
}
if (str_starts_with($mime, 'video/')) {
return 'video';
}
if (str_starts_with($mime, 'audio/')) {
return 'audio';
}
if (in_array($mime, config('upload.allowed_documents'), true)) {
return 'document';
}
return 'other';
}
private function isAllowedMime(string $mime, string $fileType): bool
{
$config = config('upload');
return match ($fileType) {
'image' => in_array($mime, $config['allowed_images'], true),
'video' => in_array($mime, $config['allowed_videos'], true),
'audio' => in_array($mime, $config['allowed_audios'], true),
'document' => in_array($mime, $config['allowed_documents'], true),
default => false,
};
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
// 事件定义文件
return [
'bind' => [
],
'listen' => [
'AppInit' => [],
'HttpRun' => [],
'HttpEnd' => [],
'LogLevel' => [],
'LogWrite' => [],
],
'subscribe' => [
],
];
+10
View File
@@ -0,0 +1,10 @@
<?php
// 全局中间件定义文件
return [
// 全局请求缓存
// \think\middleware\CheckRequestCache::class,
// 多语言加载
// \think\middleware\LoadLangPack::class,
// Session初始化
// \think\middleware\SessionInit::class
];
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace app\middleware;
use think\exception\HttpResponseException;
class AdminAuth
{
public function handle($request, \Closure $next)
{
$user = $request->authUser ?? null;
if (!$user || ($user['role'] ?? '') !== 'admin') {
throw new HttpResponseException(json([
'code' => 1,
'message' => '无管理员权限',
'data' => null,
], 403));
}
return $next($request);
}
}
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace app\middleware;
use app\service\JwtService;
use think\Response;
class Cors
{
public function handle($request, \Closure $next)
{
header('Access-Control-Allow-Origin: *');
header('Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS');
header('Access-Control-Allow-Headers: Content-Type, Authorization');
if ($request->method(true) === 'OPTIONS') {
return Response::create('', 'html', 204);
}
return $next($request);
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
namespace app\middleware;
use app\model\User;
use app\service\JwtService;
use think\exception\HttpResponseException;
use think\Response;
class JwtAuth
{
public function handle($request, \Closure $next)
{
$token = $request->header('Authorization', '');
$payload = JwtService::verifyToken($token);
if (!$payload || empty($payload['user_id'])) {
$this->abort('请先登录', 401);
}
$user = User::with(['membership'])->find($payload['user_id']);
if (!$user || $user->status !== 'active') {
$this->abort('请先登录', 401);
}
$request->authUser = $this->formatUser($user);
return $next($request);
}
private function formatUser(User $user): array
{
$level = $user->membership;
return [
'id' => $user->id,
'username' => $user->username,
'email' => $user->email,
'nickname' => $user->nickname,
'avatar' => $user->avatar,
'role' => $user->role,
'status' => $user->status,
'membership_level_id' => $user->membership_level_id,
'membership_name' => $level?->name,
'membership_slug' => $level?->slug,
'max_conversations' => $level?->max_conversations ?? 20,
'max_messages_per_day' => $level?->max_messages_per_day ?? 50,
'max_upload_size_mb' => $level?->max_upload_size_mb ?? 5,
'membership_permissions' => $level?->permissions ?? [],
];
}
private function abort(string $message, int $httpCode = 400): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class AiModel extends Model
{
protected $name = 'ai_models';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\model;
use think\Model;
class Conversation extends Model
{
protected $name = 'conversations';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function user()
{
return $this->belongsTo(User::class, 'user_id');
}
public function aiModel()
{
return $this->belongsTo(AiModel::class, 'model_id');
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace app\model;
use think\Model;
class MembershipLevel extends Model
{
protected $name = 'membership_levels';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
protected $type = [
'permissions' => 'json',
'allowed_models' => 'json',
];
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\model;
use think\Model;
class Message extends Model
{
protected $name = 'messages';
protected $autoWriteTimestamp = 'created_at';
protected $createTime = 'created_at';
protected $updateTime = false;
protected $type = [
'attachments' => 'json',
];
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class SystemSetting extends Model
{
protected $name = 'system_settings';
protected $autoWriteTimestamp = 'updated_at';
protected $updateTime = 'updated_at';
protected $createTime = false;
}
+13
View File
@@ -0,0 +1,13 @@
<?php
namespace app\model;
use think\Model;
class UploadFile extends Model
{
protected $name = 'uploads';
protected $autoWriteTimestamp = 'created_at';
protected $createTime = 'created_at';
protected $updateTime = false;
}
+18
View File
@@ -0,0 +1,18 @@
<?php
namespace app\model;
use think\Model;
class User extends Model
{
protected $name = 'users';
protected $autoWriteTimestamp = true;
protected $createTime = 'created_at';
protected $updateTime = 'updated_at';
public function membership()
{
return $this->belongsTo(MembershipLevel::class, 'membership_level_id');
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
namespace app\model;
use think\Model;
class UserDailyStat extends Model
{
protected $name = 'user_daily_stats';
protected $autoWriteTimestamp = false;
}
+9
View File
@@ -0,0 +1,9 @@
<?php
use app\ExceptionHandle;
use app\Request;
// 容器Provider定义文件
return [
'think\Request' => Request::class,
'think\exception\Handle' => ExceptionHandle::class,
];
+9
View File
@@ -0,0 +1,9 @@
<?php
use app\AppService;
// 系统服务定义文件
// 服务在完成全局初始化之后执行
return [
AppService::class,
];
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace app\service;
class JwtService
{
public static function generateToken(array $payload): string
{
$secret = config('jwt.secret');
$expire = config('jwt.expire');
$header = self::base64UrlEncode(json_encode(['typ' => 'JWT', 'alg' => 'HS256']));
$payload['exp'] = time() + $expire;
$payload['iat'] = time();
$body = self::base64UrlEncode(json_encode($payload));
$signature = self::base64UrlEncode(hash_hmac('sha256', "{$header}.{$body}", $secret, true));
return "{$header}.{$body}.{$signature}";
}
public static function verifyToken(?string $token): ?array
{
if (!$token) {
return null;
}
if (str_starts_with($token, 'Bearer ')) {
$token = substr($token, 7);
}
$parts = explode('.', $token);
if (count($parts) !== 3) {
return null;
}
[$header, $body, $signature] = $parts;
$secret = config('jwt.secret');
$expected = self::base64UrlEncode(hash_hmac('sha256', "{$header}.{$body}", $secret, true));
if (!hash_equals($expected, $signature)) {
return null;
}
$payload = json_decode(self::base64UrlDecode($body), true);
if (!$payload || ($payload['exp'] ?? 0) < time()) {
return null;
}
return $payload;
}
private static function base64UrlEncode(string $data): string
{
return rtrim(strtr(base64_encode($data), '+/', '-_'), '=');
}
private static function base64UrlDecode(string $data): string
{
return base64_decode(strtr($data, '-_', '+/'));
}
}
+148
View File
@@ -0,0 +1,148 @@
<?php
namespace app\service;
use app\model\AiModel;
use think\exception\HttpResponseException;
class OpenAIService
{
public static function getModel(?int $modelId = null): AiModel
{
if ($modelId) {
$model = AiModel::where('id', $modelId)->where('enabled', 1)->find();
} else {
$model = AiModel::where('is_default', 1)->where('enabled', 1)->find();
}
if (!$model) {
$model = AiModel::where('enabled', 1)->order('sort_order')->find();
}
if (!$model) {
throw new HttpResponseException(json([
'code' => 1,
'message' => '未配置可用的 AI 模型',
'data' => null,
], 500));
}
return $model;
}
public static function streamChat(AiModel $model, array $messages, callable $onChunk): void
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = [
'model' => $model->model_id,
'messages' => $messages,
'stream' => true,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => false,
CURLOPT_WRITEFUNCTION => function ($ch, $data) use ($onChunk) {
$lines = explode("\n", $data);
foreach ($lines as $line) {
$line = trim($line);
if ($line === '' || $line === 'data: [DONE]') {
continue;
}
if (str_starts_with($line, 'data: ')) {
$json = json_decode(substr($line, 6), true);
if ($json) {
$onChunk($json);
}
}
}
return strlen($data);
},
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => false,
]);
$result = curl_exec($ch);
if ($result === false) {
$error = curl_error($ch);
curl_close($ch);
self::sseEvent('error', ['message' => 'AI 请求失败: ' . $error]);
return;
}
curl_close($ch);
}
public static function chat(AiModel $model, array $messages): array
{
$url = rtrim($model->api_base_url, '/') . '/chat/completions';
$payload = [
'model' => $model->model_id,
'messages' => $messages,
'stream' => false,
'max_tokens' => (int) $model->max_tokens,
'temperature' => (float) $model->temperature,
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => [
'Content-Type: application/json',
'Authorization: Bearer ' . $model->api_key,
],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 120,
CURLOPT_SSL_VERIFYPEER => false,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($httpCode !== 200) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 请求失败: HTTP ' . $httpCode,
'data' => null,
], 502));
}
$data = json_decode($response, true);
if (!$data) {
throw new HttpResponseException(json([
'code' => 1,
'message' => 'AI 响应解析失败',
'data' => null,
], 502));
}
return $data;
}
public static function sseHeaders(): void
{
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');
header('Connection: keep-alive');
header('X-Accel-Buffering: no');
}
public static function sseEvent(string $event, mixed $data): void
{
echo "event: {$event}\n";
echo 'data: ' . json_encode($data, JSON_UNESCAPED_UNICODE) . "\n\n";
if (ob_get_level() > 0) {
ob_flush();
}
flush();
}
}
@@ -0,0 +1,87 @@
<?php
namespace app\service;
use app\model\UserDailyStat;
use think\exception\HttpResponseException;
class PermissionService
{
public static function checkDailyLimit(array $user): void
{
$today = date('Y-m-d');
$stats = UserDailyStat::where('user_id', $user['id'])
->where('stat_date', $today)
->find();
$count = (int) ($stats->message_count ?? 0);
if ($count >= (int) $user['max_messages_per_day']) {
self::abort('今日消息数量已达上限', 429);
}
}
public static function incrementDailyCount(int $userId): void
{
$today = date('Y-m-d');
$stats = UserDailyStat::where('user_id', $userId)
->where('stat_date', $today)
->find();
if ($stats) {
$stats->inc('message_count')->save();
} else {
UserDailyStat::create([
'user_id' => $userId,
'stat_date' => $today,
'message_count' => 1,
]);
}
}
public static function checkConversationLimit(array $user): void
{
$count = \app\model\Conversation::where('user_id', $user['id'])
->whereNull('deleted_at')
->count();
if ($count >= (int) $user['max_conversations']) {
self::abort('会话数量已达上限', 429);
}
}
public static function canUpload(array $user, string $type): bool
{
$permissions = $user['membership_permissions'] ?? [];
$map = [
'image' => ['upload_image', 'can_upload_image'],
'video' => ['upload_video', 'can_upload_video'],
'document' => ['upload_file', 'can_upload_file'],
'audio' => ['voice', 'can_use_voice'],
];
if (!isset($map[$type])) {
return false;
}
[$featureKey, $permKey] = $map[$type];
if (!SettingsService::isFeatureEnabled($featureKey)) {
return false;
}
return !empty($permissions[$permKey]);
}
public static function getMaxUploadSizeMb(array $user): int
{
return min((int) $user['max_upload_size_mb'], (int) config('upload.max_size_mb'));
}
private static function abort(string $message, int $httpCode = 400): void
{
throw new HttpResponseException(json([
'code' => 1,
'message' => $message,
'data' => null,
], $httpCode));
}
}
@@ -0,0 +1,58 @@
<?php
namespace app\service;
use app\model\SystemSetting;
class SettingsService
{
public static function get(string $key, mixed $default = null): mixed
{
$row = SystemSetting::where('setting_key', $key)->find();
if (!$row) {
return $default;
}
$decoded = json_decode($row->setting_value, true);
return json_last_error() === JSON_ERROR_NONE ? $decoded : $row->setting_value;
}
public static function set(string $key, mixed $value): void
{
$stored = is_array($value) || is_object($value)
? json_encode($value, JSON_UNESCAPED_UNICODE)
: (string) $value;
$setting = SystemSetting::where('setting_key', $key)->find();
if ($setting) {
$setting->save(['setting_value' => $stored]);
} else {
SystemSetting::create([
'setting_key' => $key,
'setting_value' => $stored,
]);
}
}
public static function getFeatures(): array
{
return self::get('features', [
'markdown' => true,
'image' => true,
'video' => true,
'voice' => true,
'document' => true,
'emoji' => true,
'upload_image' => true,
'upload_video' => true,
'upload_file' => true,
'paste_image' => true,
]);
}
public static function isFeatureEnabled(string $feature): bool
{
$features = self::getFeatures();
return !empty($features[$feature]);
}
}