AI代码生成工具

This commit is contained in:
李琦
2026-08-10 15:51:00 +08:00
parent d0018a0173
commit bd226c260f
31 changed files with 5468 additions and 31 deletions

View File

@@ -79,7 +79,12 @@ VITE_APP_NAME="${APP_NAME}"
BASE_OLD_API_URL=http://www.xk888.com
BASE_OLD_SHOP_API_URL=https://shop.xiaokang88.com
# 密钥
# 密钥(库内敏感字段 AES前缀 nl_ase_256_
ENCRYPT_KEY=3a8f9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a9b2d7c1e4f8a
# JWT HS256 密钥(至少 32 字符;生产环境务必改成随机长串)
JWT_SECRET=nl_admin_jwt_secret_key_change_me_32b
# AI 供应商环境回落(库内未配置密钥时可用)
AI_DEFAULT_PROVIDER=deepseek
SPARK_API_PASSWORD=
DEEPSEEK_API_KEY=

View File

@@ -0,0 +1,128 @@
<?php
namespace App\Core;
use Exception;
use RuntimeException;
/**
* 库内敏感字段 AES-256-CBC 加解密
* 密文格式nl_ase_256_ + base64(iv + ciphertext)
* 密钥派生hash('sha256', ENCRYPT_KEY, true) 32 字节
* 读取时兼容历史前缀 ***(迁移期)
*/
class DatabaseEncryptor
{
/** 新密文前缀(入库统一用这个) */
public const PREFIX = 'nl_ase_256_';
/** 历史密文前缀(仅解密兼容) */
public const LEGACY_PREFIX = '***';
private string $cipherMethod = 'AES-256-CBC';
private string $secretKey;
private int $ivLength;
/**
* @param string $key 来自 config('nl.encrypt_key') / env ENCRYPT_KEY
*/
public function __construct(string $key)
{
$key = trim($key);
if ($key === '') {
throw new RuntimeException('AES 加密密钥未配置(请设置 ENCRYPT_KEY');
}
$this->secretKey = hash('sha256', $key, true);
$ivLength = openssl_cipher_iv_length($this->cipherMethod);
if ($ivLength === false) {
throw new RuntimeException('不支持的加密算法:' . $this->cipherMethod);
}
$this->ivLength = $ivLength;
}
/**
* 加密后入库;空串不加密,已是密文则原样返回避免二次加密
*
* @param string|null $data 明文
* @throws Exception
*/
public function encrypt($data): string
{
if ($data === null || $data === '') {
return '';
}
$data = (string) $data;
if (self::isEncrypted($data)) {
return $data;
}
$iv = openssl_random_pseudo_bytes($this->ivLength);
if ($iv === false) {
throw new RuntimeException('生成 IV 失败');
}
$encryptedData = openssl_encrypt($data, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($encryptedData === false) {
throw new RuntimeException('加密失败:' . (openssl_error_string() ?: 'unknown'));
}
return self::PREFIX . base64_encode($iv . $encryptedData);
}
/**
* 从库内密文解密;无已知前缀视为明文原样返回
*
* @param string|null $base64EncodedEncryptedData 库内值
* @throws Exception
*/
public function decrypt($base64EncodedEncryptedData): string
{
if ($base64EncodedEncryptedData === null || $base64EncodedEncryptedData === '') {
return '';
}
$base64EncodedEncryptedData = (string) $base64EncodedEncryptedData;
$prefix = self::detectPrefix($base64EncodedEncryptedData);
if ($prefix === null) {
return $base64EncodedEncryptedData;
}
$payload = substr($base64EncodedEncryptedData, strlen($prefix));
$decodedData = base64_decode($payload, true);
if ($decodedData === false) {
throw new RuntimeException('Base64 解码失败');
}
if (strlen($decodedData) <= $this->ivLength) {
throw new RuntimeException('密文长度非法');
}
$iv = substr($decodedData, 0, $this->ivLength);
$encryptedData = substr($decodedData, $this->ivLength);
$decryptedData = openssl_decrypt($encryptedData, $this->cipherMethod, $this->secretKey, OPENSSL_RAW_DATA, $iv);
if ($decryptedData === false) {
throw new RuntimeException('解密失败:' . (openssl_error_string() ?: 'unknown'));
}
return $decryptedData;
}
/**
* 是否已是库内密文(含历史前缀)
*/
public static function isEncrypted(?string $value): bool
{
return self::detectPrefix($value) !== null;
}
/**
* 识别密文前缀;新前缀优先
*/
public static function detectPrefix(?string $value): ?string
{
if (!is_string($value) || $value === '') {
return null;
}
if (str_starts_with($value, self::PREFIX)) {
return self::PREFIX;
}
if (str_starts_with($value, self::LEGACY_PREFIX)) {
return self::LEGACY_PREFIX;
}
return null;
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
/**
* AI 生成场景(本项目可扩展,勿塞入其他业务域专有场景)
*/
enum AiGenerationSceneEnum: string
{
/** 根据描述直接生成代码结构 JSON */
case CODE_GENERATION = 'code_generation';
/** 根据模块名生成/优化需求概览与代码生成提示词 */
case REQUIREMENT_PROMPT = 'requirement_prompt';
public function description(): string
{
return match ($this) {
self::CODE_GENERATION => '代码生成',
self::REQUIREMENT_PROMPT => '需求概览/提示词',
};
}
}

View File

@@ -0,0 +1,22 @@
<?php
namespace App\Enum;
/**
* AI 生成记录状态
*/
enum AiGenerationStatusEnum: int
{
case RUNNING = 0;
case SUCCESS = 1;
case FAIL = 2;
public function description(): string
{
return match ($this) {
self::RUNNING => '进行中',
self::SUCCESS => '成功',
self::FAIL => '失败',
};
}
}

View File

@@ -0,0 +1,168 @@
<?php
namespace App\Http\Controllers\Api;
use App\BaseApp\BaseController;
use App\Service\AiConfigService;
use Illuminate\Http\JsonResponse;
/**
* AI 配置控制器:运行时选用 + 密钥管理
*/
class AiConfigController extends BaseController
{
public function __construct()
{
parent::__construct();
$this->service = AiConfigService::getInstance();
}
/**
* 禁用基类通用列表(本模块用 keyList
* @Method NO
*/
public function list(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function option(): mixed
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function detail(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function create(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function update(): JsonResponse
{
return jerr('不支持');
}
/**
* @Method NO
*/
public function delete(): JsonResponse
{
return jerr('不支持');
}
/**
* 获取 AI 管理页选项(平台/模型/密钥卡片数据 + 当前激活项)
* @Method GET
*/
public function runtimeOptions(): JsonResponse
{
return jok($this->service->getRuntimeOptions(), '获取成功');
}
/**
* 保存默认 AI 平台/模型/密钥组合
* @Method POST
*/
public function saveRuntime(): JsonResponse
{
$params = request()->post();
return jok($this->service->saveRuntime($params), '保存成功');
}
/**
* 密钥列表(卡片展示)
* @Method GET
*/
public function keyList(): JsonResponse
{
return jok($this->service->listKeys(), '获取成功');
}
/**
* 平台下拉
* @Method GET
*/
public function platformOption(): JsonResponse
{
return jok($this->service->platformOptions(), '获取成功');
}
/**
* 新增密钥
* @Method POST
*/
public function createKey(): JsonResponse
{
$params = request()->post();
return jok($this->service->createKey($params), '创建成功');
}
/**
* 更新密钥
* @Method POST
*/
public function updateKey(): JsonResponse
{
$params = request()->post();
$id = (int) ($params['id'] ?? 0);
if ($id <= 0) {
return jerr('参数错误');
}
unset($params['id']);
return jok($this->service->updateKey($id, $params), '更新成功');
}
/**
* 删除密钥
* @Method POST
*/
public function deleteKey(): JsonResponse
{
$ids = request()->post('ids');
if (empty($ids)) {
return jerr('参数错误');
}
if (!is_array($ids)) {
$ids = [$ids];
}
return jok($this->service->deleteKeys($ids), '删除成功');
}
/**
* AI 生成历史列表
* @Method GET
*/
public function generationList(): JsonResponse
{
return jok($this->service->generationList(), '获取成功');
}
/**
* AI 生成历史详情
* @Method GET
*/
public function generationDetail(): JsonResponse
{
$id = (int) request()->get('id', 0);
if ($id <= 0) {
return jerr('参数错误');
}
return jok($this->service->generationDetail($id), '获取成功');
}
}

View File

@@ -3,8 +3,9 @@
namespace App\Http\Controllers\core;
use App\BaseApp\BaseController;
use App\Service\common\ai\AiCodeGenService;
use App\Service\common\ai\AiRequirementPromptService;
use App\Service\core\CodeGenerationService;
use App\Service\LoginService;
use Illuminate\Http\JsonResponse;
class CodeGenerationController extends BaseController
@@ -156,8 +157,34 @@ class CodeGenerationController extends BaseController
}
$codeGenData = $this->service->convertTableToCodeGenData($tableName, $className, $classComment, $icon, $sort, $pid);
return jok($codeGenData);
}
/**
* AI 提示词生成代码结构(调用工厂 Agent返回可灌入表单的 JSON
* @Method POST
*/
public function aiGenerate(): JsonResponse
{
$params = request()->post();
return jok(
AiCodeGenService::getInstance()->generate($params),
'AI 生成成功'
);
}
/**
* 根据模块名生成 / 优化需求概览与代码生成提示词(写入生成历史,供前端替换导入)
* @Method POST
*/
public function aiRequirementPrompt(): JsonResponse
{
$params = request()->post();
return jok(
AiRequirementPromptService::getInstance()->generate($params),
'需求概览生成成功'
);
}
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models;
use App\BaseApp\BaseModel;
/**
* 系统配置表模型kv 存储)
*/
class SystemConfigModel extends BaseModel
{
protected $table = 'system_config';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI API 密钥表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiApiKeyModel extends BaseModel
{
protected $table = 'ai_api_key';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 生成历史表模型(仅表名与字段约定)
*/
class AiGenerationModel extends BaseModel
{
protected $table = 'ai_generation';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 模型表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiModelModel extends BaseModel
{
protected $table = 'ai_model';
protected $guarded = [];
}

View File

@@ -0,0 +1,15 @@
<?php
namespace App\Models\ai;
use App\BaseApp\BaseModel;
/**
* AI 平台表模型(仅表名与字段约定,查询逻辑放 Service
*/
class AiPlatformModel extends BaseModel
{
protected $table = 'ai_platform';
protected $guarded = [];
}

View File

@@ -0,0 +1,227 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\ai\AiApiKeyModel;
use App\Models\ai\AiPlatformModel;
use App\Service\common\ai\AiGenerationLogService;
use App\Service\common\ai\AiRuntimeConfigService;
use App\Service\common\FieldEncryptService;
/**
* AI 配置管理 Service密钥 CRUD + 运行时选项读写
* 平台/模型以种子数据为主,密钥需后台录入后才能真正调用
*/
class AiConfigService extends BaseService
{
public function __construct()
{
parent::__construct();
$this->model = AiApiKeyModel::class;
$this->selectField = ['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'created_at', 'updated_at'];
$this->queryField = [
'platform_id' => '=',
'name' => 'like',
'status' => '=',
];
$this->orderBy = [
'name' => 'sort',
'sort' => 'desc',
];
}
/**
* 获取 AI 管理页选项(平台卡片 + 当前激活组合)
*/
public function getRuntimeOptions(): array
{
return AiRuntimeConfigService::getInstance()->optionsForConfig();
}
/**
* 保存默认平台/模型/密钥
*/
public function saveRuntime(array $params): array
{
$provider = (string) ($params['provider'] ?? '');
$model = (string) ($params['model'] ?? '');
$apiKeyId = (int) ($params['api_key_id'] ?? 0);
return AiRuntimeConfigService::getInstance()->saveActiveConfig($provider, $model, $apiKeyId);
}
/**
* 密钥列表(卡片展示用,不返回明文密钥)
* 直接查表,避免单例 where update 污染导致列表为空
*/
public function listKeys(): array
{
$items = AiApiKeyModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderByDesc('id')
->get(['id', 'platform_id', 'name', 'remark', 'is_default', 'status', 'sort', 'api_key', 'created_at', 'updated_at'])
->toArray();
$platforms = AiPlatformModel::where('deleted_at', 0)
->get(['id', 'code', 'name', 'logo'])
->keyBy('id')
->toArray();
foreach ($items as &$item) {
$pid = (int) ($item['platform_id'] ?? 0);
$item['platform'] = $platforms[$pid] ?? null;
$item['has_key'] = trim((string) ($item['api_key'] ?? '')) !== '';
unset($item['api_key']);
}
unset($item);
return [
'page' => 1,
'size' => count($items),
'page_count' => 1,
'total' => count($items),
'items' => $items,
];
}
/**
* 平台下拉(密钥表单用)
*/
public function platformOptions(): array
{
return AiPlatformModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'code', 'name', 'logo'])
->toArray();
}
/**
* 新增密钥(明文入库前加密)
*/
public function createKey(array $params): mixed
{
$platformId = (int) ($params['platform_id'] ?? 0);
$name = trim((string) ($params['name'] ?? ''));
$apiKey = trim((string) ($params['api_key'] ?? ''));
if ($platformId <= 0) {
$this->utils->errorThrow('请选择所属平台');
}
if ($name === '') {
$this->utils->errorThrow('请填写密钥别名');
}
if ($apiKey === '') {
$this->utils->errorThrow('请填写 API Key');
}
$platform = AiPlatformModel::where('id', $platformId)
->where('deleted_at', 0)
->first();
if (!$platform) {
$this->utils->errorThrow('平台不存在');
}
$isDefault = (int) ($params['is_default'] ?? 0);
if ($isDefault === 1) {
// 同平台只保留一个默认密钥
AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->update(['is_default' => 0, 'updated_at' => time()]);
}
return $this->insert([
'platform_id' => $platformId,
'name' => $name,
'api_key' => FieldEncryptService::getInstance()->encryptForStorage($apiKey),
'remark' => trim((string) ($params['remark'] ?? '')),
'is_default' => $isDefault,
'status' => (int) ($params['status'] ?? 1),
'sort' => (int) ($params['sort'] ?? 0),
'created_at' => time(),
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 更新密钥api_key 为空表示不改密钥本身
*/
public function updateKey(int $id, array $params): mixed
{
$info = AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$info) {
$this->utils->notFound('密钥不存在');
}
$data = [];
if (array_key_exists('name', $params)) {
$name = trim((string) $params['name']);
if ($name === '') {
$this->utils->errorThrow('密钥别名不能为空');
}
$data['name'] = $name;
}
if (array_key_exists('remark', $params)) {
$data['remark'] = trim((string) $params['remark']);
}
if (array_key_exists('status', $params)) {
$data['status'] = (int) $params['status'];
}
if (array_key_exists('sort', $params)) {
$data['sort'] = (int) $params['sort'];
}
if (array_key_exists('platform_id', $params)) {
$platformId = (int) $params['platform_id'];
if ($platformId <= 0) {
$this->utils->errorThrow('请选择所属平台');
}
$data['platform_id'] = $platformId;
}
$platformId = (int) ($data['platform_id'] ?? $info->platform_id);
if (array_key_exists('is_default', $params)) {
$isDefault = (int) $params['is_default'];
$data['is_default'] = $isDefault;
if ($isDefault === 1) {
AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('id', '<>', $id)
->update(['is_default' => 0, 'updated_at' => time()]);
}
}
$apiKey = trim((string) ($params['api_key'] ?? ''));
if ($apiKey !== '') {
$data['api_key'] = FieldEncryptService::getInstance()->encryptForStorage($apiKey);
}
if (empty($data)) {
return true;
}
$data['updated_at'] = time();
// 直接 update避免 BaseService::save 污染单例 where 导致后续列表为空
return AiApiKeyModel::where('id', $id)->where('deleted_at', 0)->update($data);
}
/**
* 软删除密钥
*/
public function deleteKeys(array $ids): mixed
{
$ids = array_values(array_filter(array_map('intval', $ids)));
if (empty($ids)) {
$this->utils->errorThrow('参数错误');
}
return AiApiKeyModel::whereIn('id', $ids)->where('deleted_at', 0)->update([
'deleted_at' => time(),
'updated_at' => time(),
]);
}
/**
* AI 生成历史列表
*/
public function generationList(): array
{
return AiGenerationLogService::getInstance()->getPageListForAdmin();
}
/**
* AI 生成历史详情
*/
public function generationDetail(int $id): array
{
return AiGenerationLogService::getInstance()->getDetailForAdmin($id);
}
}

View File

@@ -9,7 +9,7 @@ use Psr\Container\ContainerExceptionInterface;
use Psr\Container\NotFoundExceptionInterface;
/**
* 角色服务类
* 菜单服务:列表树、搜索(含子菜单)、下拉
*/
class MenuService extends BaseService
{
@@ -29,29 +29,122 @@ class MenuService extends BaseService
}
/**
* 获取列表
* 获取菜单列表(扁平树,前端 transform
* title 搜索时:命中节点 + 祖先 + 子孙,保证子菜单可搜到并完整展示路径
* 无搜索时:仍按根菜单分页,再挂两级子节点
*
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
*/
public function list()
{
// /order/product-order
$title = trim((string) request()->get('title', ''));
if ($title !== '') {
return $this->listByTitleSearch($title);
}
$this->where[] = ['pid', '=', 0];
$list = $this->getPageList();
$ids = array_column($list['items'], 'id');
$items = $this->model::whereIn('pid', $ids)->get($this->selectField);
if (empty($ids)) {
return $list;
}
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $ids)->orderBy('sort')->get($this->selectField);
$childrenIds = [];
foreach ($items as $v) {
$list['items'][] = $v;
$childrenIds[] = $v['id'];
}
$items = $this->model::whereIn('pid', $childrenIds)->get($this->selectField);
foreach ($items as $v) {
$list['items'][] = $v;
if (!empty($childrenIds)) {
$items = $this->model::where('deleted_at', 0)->whereIn('pid', $childrenIds)->orderBy('sort')->get($this->selectField);
foreach ($items as $v) {
$list['items'][] = $v;
}
}
return $list;
}
/**
* 按标题模糊搜索:返回命中项及其祖先、子孙,供树表正确拼装
* 为什么要带祖先:仅返回子节点时 pid 找不到父级,树会丢节点或挂到根外
*/
private function listByTitleSearch(string $title): array
{
$all = $this->model::where('deleted_at', 0)
->orderBy('sort')
->get($this->selectField)
->keyBy('id');
if ($all->isEmpty()) {
return $this->emptyPageList();
}
$matchedIds = [];
foreach ($all as $id => $row) {
$rowTitle = (string) ($row['title'] ?? '');
if ($rowTitle !== '' && mb_stripos($rowTitle, $title) !== false) {
$matchedIds[] = (int) $id;
}
}
if (empty($matchedIds)) {
return $this->emptyPageList();
}
// 子节点按 pid 分组,便于向下展开子孙
$childrenMap = [];
foreach ($all as $id => $row) {
$pid = (int) ($row['pid'] ?? 0);
$childrenMap[$pid][] = (int) $id;
}
$needIds = [];
foreach ($matchedIds as $mid) {
// 向上:自身 + 祖先
$cur = $mid;
while ($cur > 0 && $all->has($cur) && !isset($needIds[$cur])) {
$needIds[$cur] = true;
$cur = (int) ($all[$cur]['pid'] ?? 0);
}
// 向下:命中节点的全部子孙(搜父级时仍能看到子树)
$stack = [$mid];
while (!empty($stack)) {
$nodeId = array_pop($stack);
foreach ($childrenMap[$nodeId] ?? [] as $cid) {
if (!isset($needIds[$cid])) {
$needIds[$cid] = true;
$stack[] = $cid;
}
}
}
}
$items = [];
foreach ($all as $id => $row) {
if (isset($needIds[(int) $id])) {
$items[] = $row->toArray();
}
}
return [
'items' => $items,
'page' => 1,
'size' => count($items),
'total' => count($items),
];
}
/**
* 空分页结构(与 getPageList 对齐)
*/
private function emptyPageList(): array
{
return [
'items' => [],
'page' => 1,
'size' => 0,
'total' => 0,
];
}
/**
* 获取树形结构的菜单下拉
* @param bool $isSelect
@@ -119,7 +212,6 @@ class MenuService extends BaseService
* @param $id
* @param $params
* @return mixed
* @throws Exception
*/
public function update($id, $params): mixed
{
@@ -131,15 +223,11 @@ class MenuService extends BaseService
/**
* 删除数据
* @param $id
* @return mixed|true
* @throws Exception
* @param $ids
* @return mixed
*/
public function delete($id): mixed
public function delete($ids): mixed
{
if (in_array($id, [1, 2])) {
$this->utils->errorThrow('管理员角色禁止删除');
}
return $this->del($id);
return $this->del($ids);
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Service;
use App\BaseApp\BaseService;
use App\Models\SystemConfigModel;
/**
* 系统配置 Service key 读写 kv AI 运行时等模块复用
*/
class SystemConfigService extends BaseService
{
public function __construct()
{
// 配置读写不依赖登录态(工厂解析时可能被其他 Service 调用)
$this->isAuth = false;
parent::__construct();
$this->model = SystemConfigModel::class;
}
/**
* 读取配置值;不存在时返回默认值
* 为什么:业务侧只关心 value不关心行结构
*/
public function getValue(string $key, mixed $default = null): mixed
{
$key = trim($key);
if ($key === '') {
return $default;
}
$row = SystemConfigModel::where('key', $key)
->where('deleted_at', 0)
->first();
if (!$row) {
return $default;
}
$value = $row->value;
if ($value === null) {
return $default;
}
return $value;
}
/**
* 写入/更新配置;存在则更新,不存在则插入
* 为什么用 upsert配置项数量少 key 幂等更稳
*/
public function setValue(string $key, mixed $value, string $remark = ''): bool
{
$key = trim($key);
if ($key === '') {
$this->utils->errorThrow('配置键不能为空');
}
if (is_array($value) || is_object($value)) {
$value = json_encode($value, JSON_UNESCAPED_UNICODE);
}
$value = (string) $value;
$now = time();
$row = SystemConfigModel::where('key', $key)
->where('deleted_at', 0)
->first();
if ($row) {
$row->value = $value;
if ($remark !== '') {
$row->remark = $remark;
}
$row->updated_at = $now;
return (bool) $row->save();
}
return (bool) SystemConfigModel::insert([
'key' => $key,
'value' => $value,
'remark' => $remark,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 批量写入配置
*
* @param array<string, mixed> $pairs key => value
*/
public function setValues(array $pairs): bool
{
foreach ($pairs as $key => $value) {
$this->setValue((string) $key, $value);
}
return true;
}
}

View File

@@ -0,0 +1,72 @@
<?php
namespace App\Service\common;
use App\BaseApp\BaseNotAuthService;
use App\Core\DatabaseEncryptor;
/**
* 库内敏感字段加解密服务API Key 等)
* 入库写 nl_ase_256_读取兼容历史 ***
*/
class FieldEncryptService extends BaseNotAuthService
{
private ?DatabaseEncryptor $encryptor = null;
/**
* 获取加解密器(懒加载,密钥来自 config/nl.php
*/
private function encryptor(): DatabaseEncryptor
{
if ($this->encryptor === null) {
$key = (string) config('nl.encrypt_key', '');
if ($key === '') {
$this->utils->errorThrow('未配置 ENCRYPT_KEY无法加解密敏感字段');
}
$this->encryptor = new DatabaseEncryptor($key);
}
return $this->encryptor;
}
/**
* 入库前加密:明文 nl_ase_256_...
*
* @param string|null $plain 明文
*/
public function encryptForStorage(?string $plain): string
{
try {
return $this->encryptor()->encrypt($plain);
} catch (\Throwable $e) {
$this->utils->errorThrow('敏感字段加密失败:' . $e->getMessage());
}
return '';
}
/**
* 读取后解密nl_ase_256_ / 历史 *** 明文
*
* @param string|null $cipher 库内值
* @param bool $silentFail true 时解密失败返回空串false 抛业务异常
*/
public function decryptFromStorage(?string $cipher, bool $silentFail = false): string
{
try {
return $this->encryptor()->decrypt($cipher);
} catch (\Throwable $e) {
if ($silentFail) {
return '';
}
$this->utils->errorThrow('敏感字段解密失败:' . $e->getMessage());
}
return '';
}
/**
* 是否已是库内密文
*/
public function isEncrypted(?string $value): bool
{
return DatabaseEncryptor::isEncrypted($value);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
/**
* AI Agent 工厂按系统配置nl_system_config + 平台表)选择供应商
* 未配置时回落 envconfig/ai.php目前支持讯飞星火 / DeepSeek
*/
class AiAgentFactory extends BaseService
{
public const PROVIDER_SPARK = 'spark';
public const PROVIDER_DEEPSEEK = 'deepseek';
public function __construct()
{
// 工厂解析不强制登录(内部调用)
$this->isAuth = false;
parent::__construct();
}
/**
* 获取 Agent 实例
*
* @param string|null $provider 为空则用系统配置 ai_active_provider / env 默认
*/
public function make(?string $provider = null): AiAgentInterface
{
$resolved = AiRuntimeConfigService::getInstance()->resolve($provider);
$name = strtolower(trim((string) ($resolved['provider'] ?? self::PROVIDER_DEEPSEEK)));
if ($name === self::PROVIDER_DEEPSEEK) {
return DeepSeekAiAgent::getInstance();
}
if ($name === self::PROVIDER_SPARK) {
return SparkAiAgent::getInstance();
}
$this->utils->errorThrow('不支持的 AI 供应商:' . $name);
return SparkAiAgent::getInstance();
}
}

View File

@@ -0,0 +1,23 @@
<?php
namespace App\Service\common\ai;
/**
* AI Agent 统一接口OpenAI 兼容 chat completions
* 各供应商(讯飞 Spark / DeepSeek 等)实现本接口,业务层只依赖工厂
*/
interface AiAgentInterface
{
/**
* 发起对话补全
*
* @param array $messages OpenAI 格式 messages[['role'=>'system|user|assistant','content'=>'...'], ...]
* @param array $options 可选覆盖model / temperature / max_tokens
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult;
/**
* 供应商标识spark / deepseek
*/
public function provider(): string;
}

View File

@@ -0,0 +1,66 @@
<?php
namespace App\Service\common\ai;
/**
* AI 对话结果 DTO
* 统一各供应商响应,便于落库与业务解析
*/
class AiChatResult
{
/** 助手文本内容(已从 choices[0].message.content 取出) */
public string $content = '';
/** 供应商原始响应数组(便于排障) */
public array $raw = [];
/** 供应商标识 */
public string $provider = '';
/** 实际使用的模型名 */
public string $model = '';
/** token 用量(若供应商返回) */
public array $usage = [];
/** 本次调用使用的 API Key ID库内密钥env 回落为 0 */
public int $apiKeyId = 0;
/**
* @param string $content 助手文本
* @param array $raw 原始响应
* @param string $provider 供应商
* @param string $model 模型
* @param array $usage usage 字段
* @param int $apiKeyId 密钥 ID
*/
public function __construct(
string $content = '',
array $raw = [],
string $provider = '',
string $model = '',
array $usage = [],
int $apiKeyId = 0
) {
$this->content = $content;
$this->raw = $raw;
$this->provider = $provider;
$this->model = $model;
$this->usage = $usage;
$this->apiKeyId = $apiKeyId;
}
/**
* 转为数组(落库 / 接口返回用)
*/
public function toArray(): array
{
return [
'content' => $this->content,
'provider' => $this->provider,
'model' => $this->model,
'usage' => $this->usage,
'api_key_id' => $this->apiKeyId,
];
}
}

View File

@@ -0,0 +1,261 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use Throwable;
/**
* 代码生成 AI 助手:根据模块名+功能描述,产出可直接灌入代码生成表单的 JSON
* 每次调用写入 nl_ai_generation便于排查用量与失败原因
*/
class AiCodeGenService extends BaseService
{
/** 允许的 MySQL 字段类型(与代码生成器一致) */
private const ALLOWED_TYPES = [
'varchar', 'int', 'tinyint', 'text', 'decimal', 'bigint', 'char', 'json',
];
/** 系统自动维护的字段,禁止 AI 再生成 */
private const RESERVED_FIELDS = [
'id', 'created_at', 'updated_at', 'deleted_at', 'status',
];
public function __construct()
{
parent::__construct();
}
/**
* 调用当前默认 AI生成代码生成标准结构
*
* @param array{module_name?:string,description?:string,pid?:int} $params
* @return array 与前端 CodeGenData 对齐的数组
*/
public function generate(array $params): array
{
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
$description = trim((string) ($params['description'] ?? ''));
$pid = (int) ($params['pid'] ?? 0);
if ($moduleName === '') {
$this->utils->errorThrow('请输入模块名');
}
if ($description === '') {
$this->utils->errorThrow('请输入功能描述');
}
$runtime = AiRuntimeConfigService::getInstance()->resolve();
$log = AiGenerationLogService::getInstance();
$generationId = $log->begin([
'scene' => AiGenerationSceneEnum::CODE_GENERATION->value,
'name' => $moduleName,
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
'admin_id' => $this->userId,
'input_snapshot' => [
'module_name' => $moduleName,
'description' => $description,
'pid' => $pid,
],
]);
try {
$agent = AiAgentFactory::getInstance()->make();
$messages = [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $pid)],
];
$result = $agent->chatCompletions($messages, [
'temperature' => 0.2,
'max_tokens' => 4096,
]);
$parsed = $this->parseJsonContent($result->content);
// 强制回填用户选择的父级菜单,避免模型改写 pid
$parsed['pid'] = $pid;
if (empty($parsed['class_comment'])) {
$parsed['class_comment'] = $moduleName;
}
if (empty($parsed['icon'])) {
$parsed['icon'] = 'lucide:box';
}
if (!isset($parsed['sort']) || $parsed['sort'] === '') {
$parsed['sort'] = 9999;
}
$this->validateResult($parsed);
$log->markSuccess($generationId, [
'provider' => $result->provider,
'model' => $result->model,
'api_key_id' => $result->apiKeyId,
'usage' => $result->usage,
'result_json' => $parsed,
'raw_response' => $result->content,
'name' => (string) ($parsed['class_comment'] ?? $moduleName),
]);
return $parsed;
} catch (Throwable $e) {
$log->markFail($generationId, $e->getMessage(), [
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
]);
throw $e;
}
}
/**
* 系统提示词:用「完整示例」约束输出,避免模型把说明文字当成字段值
* 为什么不用 schema 占位描述:模型常把 "snake_case 字段名,不要 id..." 原样抄进 name导致建表 SQL 语法错误
*/
private function buildSystemPrompt(): string
{
return <<<'PROMPT'
你是 nl-admin 后台代码生成助手。根据用户的模块名与功能描述,输出【仅一个】合法 JSON 对象。
禁止输出 markdown、代码围栏、解释文字。禁止把规则说明文字当作字段值。
【输出示例】(请按同样结构填写真实业务字段,不要照抄示例字段名):
{
"class_name": "VipMember",
"class_comment": "VIP会员",
"icon": "lucide:crown",
"sort": 9999,
"pid": 0,
"field": [
{
"name": "title",
"type": "varchar",
"type_length": "64",
"default": "",
"comment": "标题",
"not_null": true,
"formShow": true,
"tableShow": true,
"formType": "VbenInput",
"search": true,
"searchValue": "like"
},
{
"name": "level",
"type": "tinyint",
"type_length": "1",
"default": "0",
"comment": "等级",
"not_null": true,
"formShow": true,
"tableShow": true,
"formType": "InputNumber",
"search": true,
"searchValue": "="
},
{
"name": "remark",
"type": "varchar",
"type_length": "255",
"default": "",
"comment": "备注",
"not_null": false,
"formShow": true,
"tableShow": false,
"formType": "Textarea",
"search": false,
"searchValue": "="
}
]
}
硬性规则:
1. class_name仅英文 PascalCase User、OrderItem、VipMember禁止中文、空格、下划线、连字符。
2. field[].name仅小写 snake_case user_name、phone禁止中文、空格、说明性长句。
3. 禁止生成这些字段名id、status、created_at、updated_at、deleted_at系统自动加
4. type 只能是varchar、int、tinyint、text、decimal、bigint、char、json。
5. formType 只能是VbenInput、Textarea、InputNumber、VbenSelect、Upload、Avatar。
6. searchValue 只能是:=、like、><、between。
7. field 至少 2 个、最多 20 个业务字段;按描述合理推断,不要编造无关字段。
8. 只输出 JSON 对象本身,第一个字符必须是 {,最后一个字符必须是 }
PROMPT;
}
/**
* 用户提示词:带上模块上下文
*/
private function buildUserPrompt(string $moduleName, string $description, int $pid): string
{
return "模块名:{$moduleName}\n功能描述:{$description}\n父级菜单ID{$pid}\n请按系统示例结构输出 JSON填真实业务字段不要照抄示例";
}
/**
* 从模型回复中提取 JSON兼容偶发的 ```json 包裹)
*/
private function parseJsonContent(string $content): array
{
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未返回内容');
}
if (preg_match('/```(?:json)?\s*([\s\S]*?)```/i', $content, $m)) {
$content = trim($m[1]);
}
$start = strpos($content, '{');
$end = strrpos($content, '}');
if ($start === false || $end === false || $end <= $start) {
$this->utils->errorThrow('AI 返回不是有效 JSON');
}
$json = substr($content, $start, $end - $start + 1);
$data = json_decode($json, true);
if (!is_array($data)) {
$this->utils->errorThrow('AI 返回 JSON 解析失败');
}
return $data;
}
/**
* 严格校验 AI 结果,避免脏数据进入建表 SQL
* 为什么要严:模型偶发把提示词说明抄进 name/class_name会导致 CREATE TABLE 语法错误
*/
private function validateResult(array $data): void
{
$className = trim((string) ($data['class_name'] ?? ''));
if ($className === '') {
$this->utils->errorThrow('AI 未生成有效类名');
}
if (!preg_match('/^[A-Z][A-Za-z0-9]{0,63}$/', $className)) {
$this->utils->errorThrow("类名非法「{$className}」,须为英文 PascalCase如 VipMember");
}
if (empty($data['field']) || !is_array($data['field'])) {
$this->utils->errorThrow('AI 未生成字段列表');
}
if (count($data['field']) < 1) {
$this->utils->errorThrow('AI 字段列表为空');
}
$names = [];
foreach ($data['field'] as $i => $field) {
if (!is_array($field)) {
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项格式错误');
}
$name = trim((string) ($field['name'] ?? ''));
$type = strtolower(trim((string) ($field['type'] ?? '')));
if ($name === '') {
$this->utils->errorThrow('字段第 ' . ($i + 1) . ' 项缺少 name');
}
// 过滤模型把整段说明塞进 name 的情况
if (mb_strlen($name) > 64 || preg_match('/\s||。||:|\||||\(|\)/u', $name)) {
$this->utils->errorThrow("字段名非法「{$name}」,须为短小写 snake_case");
}
if (!preg_match('/^[a-z][a-z0-9_]{0,63}$/', $name)) {
$this->utils->errorThrow("字段名非法「{$name}」,须为小写 snake_case如 user_name");
}
if (in_array($name, self::RESERVED_FIELDS, true)) {
$this->utils->errorThrow("字段「{$name}」为系统保留字段,请勿生成");
}
if (!in_array($type, self::ALLOWED_TYPES, true)) {
$this->utils->errorThrow("字段「{$name}」类型非法「{$type}");
}
if (isset($names[$name])) {
$this->utils->errorThrow("字段名重复「{$name}");
}
$names[$name] = true;
}
}
}

View File

@@ -0,0 +1,197 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Models\new\ai\AiDoctorConsentModel;
use App\Models\new\ai\AiFeatureCopyModel;
use App\Models\new\vip\VipFeatureModel;
/**
* AI 醒目提示文案 + 首次知情同意
* 文案按 VIP 功能码隔离;医生签署后仅该功能无需再次确认
*/
class AiDisclaimerService extends BaseService
{
public const FEATURE_MR = 'ai_medical_record';
public const FEATURE_RX = 'ai_prescription';
/**
* 系统配置:启用中的文案列表(含 VIP 功能名)
*/
public function listCopiesForConfig(): array
{
$rows = AiFeatureCopyModel::where('deleted_at', 0)
->orderByDesc('sort')
->orderBy('id')
->get()
->toArray();
$nameMap = VipFeatureModel::where('deleted_at', 0)
->whereIn('code', array_column($rows, 'feature_code'))
->get(['code', 'name'])
->keyBy('code')
->toArray();
foreach ($rows as &$row) {
$code = (string) ($row['feature_code'] ?? '');
$row['feature_name'] = $nameMap[$code]['name'] ?? $code;
$row['disclaimer_text'] = (string) ($row['disclaimer_text'] ?? '');
$row['consent_text'] = (string) ($row['consent_text'] ?? '');
}
unset($row);
return $rows;
}
/**
* 批量保存文案(系统配置 AI 模型 Tab
*
* @param list<array{id?:int,feature_code?:string,record_label?:string,disclaimer_text?:string,consent_text?:string}> $items
*/
public function saveCopies(array $items): bool
{
if (empty($items)) {
$this->utils->errorThrow('无保存内容');
}
$now = time();
foreach ($items as $item) {
if (!is_array($item)) {
continue;
}
$id = (int) ($item['id'] ?? 0);
$code = trim((string) ($item['feature_code'] ?? ''));
$row = null;
if ($id > 0) {
$row = AiFeatureCopyModel::where('id', $id)->where('deleted_at', 0)->first();
}
if (!$row && $code !== '') {
$row = AiFeatureCopyModel::where('feature_code', $code)->where('deleted_at', 0)->first();
}
if (!$row) {
continue;
}
if (array_key_exists('record_label', $item)) {
$row->record_label = trim((string) $item['record_label']);
}
if (array_key_exists('disclaimer_text', $item)) {
$row->disclaimer_text = (string) $item['disclaimer_text'];
}
if (array_key_exists('consent_text', $item)) {
$row->consent_text = (string) $item['consent_text'];
}
$row->updated_at = $now;
$row->save();
}
return true;
}
/**
* 按功能码取文案;不存在则回落内置默认
*/
public function getCopyByFeature(string $featureCode): array
{
$featureCode = trim($featureCode);
if ($featureCode === '') {
$this->utils->errorThrow('功能码不能为空');
}
$row = AiFeatureCopyModel::where('feature_code', $featureCode)
->where('deleted_at', 0)
->where('status', 1)
->first();
if ($row) {
return [
'id' => (int) $row->id,
'feature_code' => (string) $row->feature_code,
'record_label' => (string) ($row->record_label ?: $this->defaultRecordLabel($featureCode)),
'disclaimer_text' => (string) ($row->disclaimer_text ?: $this->defaultDisclaimer($featureCode)),
'consent_text' => (string) ($row->consent_text ?: $this->defaultConsent()),
];
}
return [
'id' => 0,
'feature_code' => $featureCode,
'record_label' => $this->defaultRecordLabel($featureCode),
'disclaimer_text' => $this->defaultDisclaimer($featureCode),
'consent_text' => $this->defaultConsent(),
];
}
/**
* 门闸:文案 + 是否已签署(按医生+功能码)
*/
public function getGate(string $featureCode, int $doctorId): array
{
if ($doctorId <= 0) {
$this->utils->errorThrow('无法识别医生身份');
}
$copy = $this->getCopyByFeature($featureCode);
$consented = AiDoctorConsentModel::where('doctor_id', $doctorId)
->where('feature_code', $featureCode)
->where('deleted_at', 0)
->exists();
return array_merge($copy, [
'consented' => $consented,
]);
}
/**
* 医生签署知情同意(同一功能幂等)
*/
public function agree(string $featureCode, int $doctorId, int $storeId = 0): array
{
if ($doctorId <= 0) {
$this->utils->errorThrow('无法识别医生身份');
}
$featureCode = trim($featureCode);
if ($featureCode === '') {
$this->utils->errorThrow('功能码不能为空');
}
$copy = $this->getCopyByFeature($featureCode);
$exists = AiDoctorConsentModel::where('doctor_id', $doctorId)
->where('feature_code', $featureCode)
->where('deleted_at', 0)
->first();
if ($exists) {
return [
'consented' => true,
'feature_code' => $featureCode,
'already' => true,
];
}
$now = time();
AiDoctorConsentModel::query()->insert([
'doctor_id' => $doctorId,
'feature_code' => $featureCode,
'copy_id' => (int) ($copy['id'] ?? 0),
'store_id' => $storeId,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
return [
'consented' => true,
'feature_code' => $featureCode,
'already' => false,
];
}
private function defaultRecordLabel(string $featureCode): string
{
return $featureCode === self::FEATURE_RX ? '处方' : '病历';
}
private function defaultDisclaimer(string $featureCode): string
{
$label = $this->defaultRecordLabel($featureCode);
return '本' . $label . '由 AI 根据问诊/语音转写内容辅助生成,仅供起草参考。'
. "\nAI 可能存在漏记、误读或“幻觉”,不能替代医师专业判断。"
. "\n诊断、处置、用药、知情告知等关键内容须由你逐条核对、修改后确认。";
}
private function defaultConsent(): string
{
return "我已知悉:\n"
. "1. 本平台 AI 仅用于病历起草、信息归纳、术语规范化;\n"
. "2. 不得用于自动诊断、自动开嘱、自动出报告;\n"
. "3. 所有 AI 输出须由具备资质的医师审核、修改、电子签名;\n"
. "4. 因未审核直接使用 AI 内容导致的医疗差错,由操作医师及医疗机构承担责任。";
}
}

View File

@@ -0,0 +1,230 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use App\Enum\AiGenerationStatusEnum;
use App\Models\ai\AiGenerationModel;
/**
* AI 生成历史落库:开始 / 成功 / 失败
* 为什么单独抽:多场景复用同一流水表,业务只关心 begin/finish
*/
class AiGenerationLogService extends BaseService
{
/**
* 创建一条「进行中」记录,返回主键 ID
*
* @param array{
* scene?:string,
* name?:string,
* provider?:string,
* model?:string,
* api_key_id?:int,
* input_snapshot?:array|null,
* admin_id?:int
* } $params
*/
public function begin(array $params): int
{
$now = time();
$scene = trim((string) ($params['scene'] ?? AiGenerationSceneEnum::CODE_GENERATION->value));
$adminId = (int) ($params['admin_id'] ?? $this->userId);
return (int) AiGenerationModel::insertGetId([
'admin_id' => $adminId,
'scene' => $scene,
'name' => mb_substr(trim((string) ($params['name'] ?? '')), 0, 128),
'provider' => trim((string) ($params['provider'] ?? '')),
'model' => trim((string) ($params['model'] ?? '')),
'status' => AiGenerationStatusEnum::RUNNING->value,
'api_key_id' => (int) ($params['api_key_id'] ?? 0),
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
'usage_json' => null,
'started_at' => $now,
'finished_at' => 0,
'duration_ms' => 0,
'error_msg' => '',
'input_snapshot' => $this->encodeJson($params['input_snapshot'] ?? null),
'result_json' => null,
'raw_response' => null,
'created_at' => $now,
'updated_at' => 0,
'deleted_at' => 0,
]);
}
/**
* 标记成功并写入结果 / token
*
* @param array{
* provider?:string,
* model?:string,
* api_key_id?:int,
* usage?:array,
* result_json?:array|null,
* raw_response?:string|null,
* name?:string
* } $payload
*/
public function markSuccess(int $id, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::SUCCESS->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'prompt_tokens' => (int) ($usage['prompt_tokens'] ?? 0),
'completion_tokens' => (int) ($usage['completion_tokens'] ?? 0),
'total_tokens' => (int) ($usage['total_tokens'] ?? 0),
'usage_json' => $this->encodeJson($usage ?: null),
'result_json' => $this->encodeJson($payload['result_json'] ?? null),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'error_msg' => '',
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
if (!empty($payload['name'])) {
$data['name'] = mb_substr(trim((string) $payload['name']), 0, 128);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 标记失败
*/
public function markFail(int $id, string $errorMsg, array $payload = []): bool
{
if ($id <= 0) {
return false;
}
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
return false;
}
$finishedAt = time();
$startedAt = (int) ($row->getRawOriginal('started_at') ?? 0);
$data = [
'status' => AiGenerationStatusEnum::FAIL->value,
'finished_at' => $finishedAt,
'duration_ms' => $this->calcDurationMs($startedAt, $finishedAt),
'error_msg' => mb_substr(trim($errorMsg), 0, 1000),
'raw_response' => $this->clipText((string) ($payload['raw_response'] ?? ''), 65000),
'updated_at' => $finishedAt,
];
if (!empty($payload['provider'])) {
$data['provider'] = (string) $payload['provider'];
}
if (!empty($payload['model'])) {
$data['model'] = (string) $payload['model'];
}
if (array_key_exists('api_key_id', $payload)) {
$data['api_key_id'] = (int) $payload['api_key_id'];
}
$usage = is_array($payload['usage'] ?? null) ? $payload['usage'] : [];
if (!empty($usage)) {
$data['prompt_tokens'] = (int) ($usage['prompt_tokens'] ?? 0);
$data['completion_tokens'] = (int) ($usage['completion_tokens'] ?? 0);
$data['total_tokens'] = (int) ($usage['total_tokens'] ?? 0);
$data['usage_json'] = $this->encodeJson($usage);
}
return (bool) AiGenerationModel::where('id', $id)->update($data);
}
/**
* 分页列表(管理端生成记录)
*/
public function getPageListForAdmin(): array
{
$this->model = AiGenerationModel::class;
$this->selectField = [
'id', 'admin_id', 'scene', 'name', 'provider', 'model', 'status',
'api_key_id', 'prompt_tokens', 'completion_tokens', 'total_tokens',
'started_at', 'finished_at', 'duration_ms', 'error_msg', 'created_at',
];
$this->queryField = [
'scene' => '=',
'status' => '=',
'provider' => '=',
'name' => 'like',
];
$this->orderBy = [
'name' => 'id',
'sort' => 'desc',
];
// 重置 where避免单例污染
$this->where = [['deleted_at', '=', 0]];
$list = $this->getPageList();
foreach ($list['items'] as &$item) {
$status = (int) ($item['status'] ?? 0);
$scene = (string) ($item['scene'] ?? '');
$item['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$item['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
}
unset($item);
return $list;
}
/**
* 详情(含 input/result不含超大 raw 时可按需)
*/
public function getDetailForAdmin(int $id): array
{
$row = AiGenerationModel::where('id', $id)->where('deleted_at', 0)->first();
if (!$row) {
$this->utils->notFound('记录不存在');
}
$data = $row->toArray();
$status = (int) ($data['status'] ?? 0);
$scene = (string) ($data['scene'] ?? '');
$data['status_text'] = AiGenerationStatusEnum::tryFrom($status)?->description() ?? '未知';
$data['scene_text'] = AiGenerationSceneEnum::tryFrom($scene)?->description() ?? $scene;
return $data;
}
private function calcDurationMs(int $startedAt, int $finishedAt): int
{
if ($startedAt <= 0 || $finishedAt <= 0 || $finishedAt < $startedAt) {
return 0;
}
return ($finishedAt - $startedAt) * 1000;
}
private function encodeJson(mixed $value): ?string
{
if ($value === null) {
return null;
}
if (is_string($value)) {
return $value;
}
return json_encode($value, JSON_UNESCAPED_UNICODE);
}
private function clipText(string $text, int $max): string
{
if ($text === '' || mb_strlen($text) <= $max) {
return $text;
}
return mb_substr($text, 0, $max);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Enum\AiGenerationSceneEnum;
use Throwable;
/**
* 需求概览 / 提示词助手:按模块名生成或优化「适配 nl-admin 代码生成器」的中文文案
* 模型只输出纯文本(不要 JSON落库时仍包一层 result_json 方便历史查询
*/
class AiRequirementPromptService extends BaseService
{
public function __construct()
{
parent::__construct();
}
/**
* 生成或优化功能描述文案(纯文本)
*
* @param array{module_name?:string,description?:string,mode?:string} $params
* mode: generate=仅模块名生成optimize=在已有描述上优化;空则自动判断
* @return array{text:string,prompt:string,overview:string,mode:string,module_name:string}
*/
public function generate(array $params): array
{
$moduleName = trim((string) ($params['module_name'] ?? $params['moduleName'] ?? ''));
$description = trim((string) ($params['description'] ?? ''));
$mode = trim((string) ($params['mode'] ?? ''));
if ($moduleName === '') {
$this->utils->errorThrow('请输入模块名');
}
if ($mode === '') {
$mode = $description !== '' ? 'optimize' : 'generate';
}
if (!in_array($mode, ['generate', 'optimize'], true)) {
$this->utils->errorThrow('mode 仅支持 generate 或 optimize');
}
if ($mode === 'optimize' && $description === '') {
$this->utils->errorThrow('优化模式请先填写当前功能描述');
}
$runtime = AiRuntimeConfigService::getInstance()->resolve();
$log = AiGenerationLogService::getInstance();
$generationId = $log->begin([
'scene' => AiGenerationSceneEnum::REQUIREMENT_PROMPT->value,
'name' => $moduleName,
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
'admin_id' => $this->userId,
'input_snapshot' => [
'module_name' => $moduleName,
'description' => $description,
'mode' => $mode,
],
]);
try {
$agent = AiAgentFactory::getInstance()->make();
$messages = [
['role' => 'system', 'content' => $this->buildSystemPrompt()],
['role' => 'user', 'content' => $this->buildUserPrompt($moduleName, $description, $mode)],
];
$result = $agent->chatCompletions($messages, [
'temperature' => 0.35,
'max_tokens' => 2048,
]);
$text = $this->normalizePlainText($result->content);
$normalized = [
// text 为主字段prompt/overview 兼容旧前端「替换导入」
'text' => $text,
'prompt' => $text,
'overview' => $text,
'mode' => $mode,
'module_name' => $moduleName,
];
$log->markSuccess($generationId, [
'provider' => $result->provider,
'model' => $result->model,
'api_key_id' => $result->apiKeyId,
'usage' => $result->usage,
'result_json' => $normalized,
'raw_response' => $result->content,
'name' => $moduleName,
]);
return $normalized;
} catch (Throwable $e) {
$log->markFail($generationId, $e->getMessage(), [
'provider' => (string) ($runtime['provider'] ?? ''),
'model' => (string) ($runtime['model'] ?? ''),
'api_key_id' => (int) ($runtime['api_key_id'] ?? 0),
]);
throw $e;
}
}
/**
* 系统提示:只要中文功能描述文案,禁止 JSON / markdown 代码块
*/
private function buildSystemPrompt(): string
{
return <<<'PROMPT'
你是 nl-adminVben Admin + Laravel 三层 CRUD 代码生成器)的需求分析助手。
任务:写出一段可直接填入「功能描述」输入框的中文文案,供下一步 AI 生成表结构与 CRUD 代码使用。
必须贴合本系统:
1. 面向后台管理 CRUD列表、搜索、新增/编辑、软删除、启停。
2. 不要要求 id / status / created_at / updated_at / deleted_at系统自动加
3. 说明要管理哪些业务数据、建议字段(用中文含义即可)、哪些适合检索、哪些适合下拉/上传等。
4. 不要写接口、鉴权、微服务、路由实现细节。
5. 200500 字为宜,条理清晰,可用分点,但不要标题堆砌。
输出要求(非常重要):
- 只输出中文正文文案本身。
- 禁止输出 JSON、禁止用 ``` 代码块、禁止前言后语(如「好的」「如下」)。
PROMPT;
}
/**
* 用户提示:生成 vs 优化
*/
private function buildUserPrompt(string $moduleName, string $description, string $mode): string
{
if ($mode === 'optimize') {
return "请优化下面的功能描述,使其更适合 nl-admin 代码生成(保留原意,补全字段与管理动作)。\n模块名:{$moduleName}\n当前描述:\n{$description}";
}
return "请根据模块名「{$moduleName}」写一段后台管理功能描述文案(适配 nl-admin 代码生成)。";
}
/**
* 清洗模型回复为纯文案(去掉偶发的 markdown / JSON 包裹)
*/
private function normalizePlainText(string $content): string
{
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未返回内容');
}
// 去掉 ``` 包裹
if (preg_match('/```(?:\w+)?\s*([\s\S]*?)```/i', $content, $m)) {
$content = trim($m[1]);
}
// 若模型仍返回 JSON尽量抽出 prompt/text/overview
if (str_starts_with($content, '{')) {
$decoded = json_decode($content, true);
if (is_array($decoded)) {
$content = trim((string) ($decoded['text'] ?? $decoded['prompt'] ?? $decoded['overview'] ?? ''));
}
}
$content = trim($content);
// 去掉常见客套开头
$content = preg_replace('/^(好的[,。]?|如下[:]?|以下是[^\\n]*[:]?)\\s*/u', '', $content) ?? $content;
$content = trim($content);
if ($content === '') {
$this->utils->errorThrow('AI 未生成有效文案');
}
return $content;
}
}

View File

@@ -0,0 +1,286 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use App\Models\ai\AiApiKeyModel;
use App\Models\ai\AiModelModel;
use App\Models\ai\AiPlatformModel;
use App\Service\common\FieldEncryptService;
use App\Service\SystemConfigService;
/**
* AI 运行时配置:从系统配置 + 平台/模型/密钥表解析当前要用的供应商与明文密钥
* 优先数据库配置;未配置时回落 envconfig/ai.php
*/
class AiRuntimeConfigService extends BaseService
{
public const CFG_PROVIDER = 'ai_active_provider';
public const CFG_API_KEY_ID = 'ai_active_api_key_id';
public const CFG_MODEL = 'ai_active_model';
public function __construct()
{
// 工厂解析不依赖登录态
$this->isAuth = false;
parent::__construct();
}
/**
* 解析指定供应商的调用配置
*
* @return array{provider:string,api_url:string,api_key:string,model:string,timeout:float,platform_id:int,api_key_id:int}
*/
public function resolve(?string $providerOverride = null): array
{
$sys = SystemConfigService::getInstance();
$provider = strtolower(trim((string) (
$providerOverride
?: $sys->getValue(self::CFG_PROVIDER, '')
?: config('ai.default_provider', 'deepseek')
)));
$modelOverride = trim((string) $sys->getValue(self::CFG_MODEL, ''));
$keyId = (int) $sys->getValue(self::CFG_API_KEY_ID, '0');
$platform = AiPlatformModel::where('code', $provider)
->where('deleted_at', 0)
->where('status', 1)
->first();
$apiUrl = '';
$model = '';
$apiKey = '';
$platformId = 0;
$timeout = 60.0;
if ($platform) {
$platformId = (int) $platform->id;
$apiUrl = trim((string) $platform->api_url);
// 模型:系统配置覆盖 → 模型表默认 → 模型表首个启用
if ($modelOverride !== '') {
$model = $modelOverride;
} else {
$defaultModel = AiModelModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1)
->where('is_default', 1)
->value('code');
if ($defaultModel) {
$model = trim((string) $defaultModel);
} else {
$firstModel = AiModelModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->value('code');
$model = trim((string) ($firstModel ?: ''));
}
}
// 指定密钥优先;否则默认密钥;再否则取该平台启用中的第一条
$keyQuery = AiApiKeyModel::where('platform_id', $platformId)
->where('deleted_at', 0)
->where('status', 1);
if ($keyId > 0) {
$keyRow = (clone $keyQuery)->where('id', $keyId)->first();
} else {
$keyRow = null;
}
if (!$keyRow) {
$keyRow = (clone $keyQuery)->where('is_default', 1)->first()
?: $keyQuery->orderByDesc('sort')->orderBy('id')->first();
}
if ($keyRow) {
$apiKey = $this->decryptApiKey((string) $keyRow->api_key);
$keyId = (int) $keyRow->id;
}
}
// 回落 env保证未录入密钥时仍可用旧配置
$envCfg = config('ai.' . $provider, []);
if ($apiUrl === '') {
$apiUrl = (string) ($envCfg['api_url'] ?? '');
}
if ($model === '') {
$model = (string) ($envCfg['model'] ?? '');
}
if ($apiKey === '') {
if ($provider === 'spark') {
$apiKey = (string) ($envCfg['password'] ?? '');
} else {
$apiKey = (string) ($envCfg['api_key'] ?? '');
}
}
$timeout = (float) ($envCfg['timeout'] ?? 60);
return [
'provider' => $provider,
'api_url' => $apiUrl,
'api_key' => $apiKey,
'model' => $model,
'timeout' => $timeout,
'platform_id' => $platformId,
'api_key_id' => $keyId,
];
}
/**
* 库内 AES 解密 api_key非加密串原样返回兼容过渡
* 委托 FieldEncryptService nl_ai_api_key 密文格式一致
*/
public function decryptApiKey(string $cipher): string
{
return FieldEncryptService::getInstance()->decryptFromStorage($cipher, true);
}
/**
* 明文入库前 AES 加密
* 委托 FieldEncryptService写出 nl_ase_256_ 前缀密文
*/
public function encryptApiKey(string $plain): string
{
return FieldEncryptService::getInstance()->encryptForStorage($plain);
}
/**
* 系统配置页:启用中的平台卡片 + 模型 + 密钥选项
* 为什么组装成卡片结构:前端 C 端风格按平台展示,减少二次请求
*/
public function optionsForConfig(): array
{
$platforms = AiPlatformModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('sort')
->orderBy('id')
->get()
->toArray();
$models = AiModelModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_default')
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'platform_id', 'code', 'name', 'description', 'is_default'])
->toArray();
$keys = AiApiKeyModel::where('deleted_at', 0)
->where('status', 1)
->orderByDesc('is_default')
->orderByDesc('sort')
->orderBy('id')
->get(['id', 'platform_id', 'name', 'is_default'])
->toArray();
$modelMap = [];
foreach ($models as $m) {
$pid = (int) $m['platform_id'];
$modelMap[$pid][] = [
'id' => (int) $m['id'],
'label' => $m['name'] . '' . $m['code'] . '',
'value' => (string) $m['code'],
'code' => (string) $m['code'],
'name' => (string) $m['name'],
'description' => (string) ($m['description'] ?? ''),
'is_default' => (int) ($m['is_default'] ?? 0),
];
}
$keyMap = [];
foreach ($keys as $k) {
$pid = (int) $k['platform_id'];
$name = trim((string) ($k['name'] ?? ''));
if ($name === '') {
$name = '密钥#' . (int) $k['id'];
}
$keyMap[$pid][] = [
'id' => (int) $k['id'],
'key_name' => $name,
'name' => $name,
'label' => $name,
'value' => (int) $k['id'],
'is_default' => (int) ($k['is_default'] ?? 0),
];
}
$cards = [];
foreach ($platforms as $p) {
$id = (int) $p['id'];
$platModels = $modelMap[$id] ?? [];
$defaultModel = '';
foreach ($platModels as $pm) {
if ((int) $pm['is_default'] === 1) {
$defaultModel = $pm['code'];
break;
}
}
if ($defaultModel === '' && !empty($platModels[0]['code'])) {
$defaultModel = (string) $platModels[0]['code'];
}
$cards[] = [
'id' => $id,
'code' => $p['code'],
'name' => $p['name'],
'logo' => $p['logo'],
'description' => $p['description'],
'default_model' => $defaultModel,
'api_url' => $p['api_url'],
'models' => $platModels,
'keys' => $keyMap[$id] ?? [],
];
}
$sys = SystemConfigService::getInstance();
return [
'platforms' => $cards,
'active' => [
'provider' => (string) $sys->getValue(self::CFG_PROVIDER, config('ai.default_provider', 'deepseek')),
'api_key_id' => (int) $sys->getValue(self::CFG_API_KEY_ID, '0'),
'model' => (string) $sys->getValue(self::CFG_MODEL, ''),
],
];
}
/**
* 保存默认运行时组合(平台 / 模型 / 密钥)
* 为什么校验平台存在:避免写入无效编码导致代码生成 AI 调用失败
*/
public function saveActiveConfig(string $provider, string $model, int $apiKeyId): array
{
$provider = strtolower(trim($provider));
$model = trim($model);
if ($provider === '') {
$this->utils->errorThrow('请选择 AI 平台');
}
$platform = AiPlatformModel::where('code', $provider)
->where('deleted_at', 0)
->where('status', 1)
->first();
if (!$platform) {
$this->utils->errorThrow('AI 平台不存在或已禁用');
}
if ($model === '') {
$this->utils->errorThrow('请选择模型');
}
$modelExists = AiModelModel::where('platform_id', (int) $platform->id)
->where('code', $model)
->where('deleted_at', 0)
->where('status', 1)
->exists();
if (!$modelExists) {
$this->utils->errorThrow('模型不属于当前平台或已禁用');
}
if ($apiKeyId > 0) {
$keyExists = AiApiKeyModel::where('id', $apiKeyId)
->where('platform_id', (int) $platform->id)
->where('deleted_at', 0)
->where('status', 1)
->exists();
if (!$keyExists) {
$this->utils->errorThrow('密钥不属于当前平台或已禁用');
}
}
$sys = SystemConfigService::getInstance();
$sys->setValue(self::CFG_PROVIDER, $provider, '当前启用的 AI 平台编码');
$sys->setValue(self::CFG_MODEL, $model, '当前启用的模型编码');
$sys->setValue(self::CFG_API_KEY_ID, (string) $apiKeyId, '当前启用的 API 密钥 ID');
return [
'provider' => $provider,
'model' => $model,
'api_key_id' => $apiKeyId,
];
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* DeepSeek AgentOpenAI 兼容协议)
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
*/
class DeepSeekAiAgent extends BaseService implements AiAgentInterface
{
public function __construct()
{
// Agent 本身不读用户态,避免工厂链路强制鉴权
$this->isAuth = false;
parent::__construct();
}
public function provider(): string
{
return 'deepseek';
}
/**
* 调用 DeepSeek chat completions
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult
{
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
$apiUrl = (string) ($cfg['api_url'] ?? '');
$apiKey = (string) ($cfg['api_key'] ?? '');
$defaultModel = (string) ($cfg['model'] ?? 'deepseek-chat');
$timeout = (float) ($cfg['timeout'] ?? 60);
if ($apiKey === '') {
$this->utils->errorThrow('DeepSeek 暂未开通请在「AI密钥管理」或 .env DEEPSEEK_API_KEY 配置)');
}
if ($apiUrl === '') {
$this->utils->errorThrow('未配置 DeepSeek 接口地址');
}
if (empty($messages)) {
$this->utils->errorThrow('AI 对话消息不能为空');
}
$model = (string) ($options['model'] ?? $defaultModel);
$body = [
'model' => $model,
'messages' => array_values($messages),
'stream' => false,
];
foreach (['temperature', 'max_tokens', 'top_p'] as $key) {
if (array_key_exists($key, $options)) {
$body[$key] = $options[$key];
}
}
try {
$client = new Client([
'timeout' => $timeout,
'connect_timeout' => min(10.0, $timeout),
]);
$response = $client->request('POST', $apiUrl, [
'headers' => [
'Authorization' => 'Bearer ' . $apiKey,
'Content-Type' => 'application/json',
],
'json' => $body,
]);
$raw = json_decode((string) $response->getBody(), true);
if (!is_array($raw)) {
$this->utils->errorThrow('DeepSeek 返回非 JSON');
}
$content = (string) data_get($raw, 'choices.0.message.content', '');
if ($content === '') {
$errMsg = (string) ($raw['error']['message'] ?? '');
$this->utils->errorThrow($errMsg !== '' ? ('DeepSeek 调用失败:' . $errMsg) : 'DeepSeek 未返回有效内容');
}
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
return new AiChatResult(
$content,
$raw,
$this->provider(),
$model,
$usage,
(int) ($cfg['api_key_id'] ?? 0)
);
} catch (Throwable $e) {
if ($e instanceof GuzzleException) {
$this->utils->errorThrow('DeepSeek 请求失败:' . $e->getMessage());
}
throw $e;
}
}
}

View File

@@ -0,0 +1,100 @@
<?php
namespace App\Service\common\ai;
use App\BaseApp\BaseService;
use GuzzleHttp\Client;
use GuzzleHttp\Exception\GuzzleException;
use Throwable;
/**
* 讯飞星火 OpenAPI AgentOpenAI 兼容 /v1/chat/completions
* 每次调用从 AiRuntimeConfigService 取最新平台/密钥/模型
*/
class SparkAiAgent extends BaseService implements AiAgentInterface
{
public function __construct()
{
// Agent 本身不读用户态,避免工厂链路强制鉴权
$this->isAuth = false;
parent::__construct();
}
public function provider(): string
{
return 'spark';
}
/**
* 调用星火 chat completions
* Authorization 使用 Bearer + API Key与官方 OpenAPI 一致)
*/
public function chatCompletions(array $messages, array $options = []): AiChatResult
{
$cfg = AiRuntimeConfigService::getInstance()->resolve($this->provider());
$apiUrl = (string) ($cfg['api_url'] ?? '');
$password = (string) ($cfg['api_key'] ?? '');
$defaultModel = (string) ($cfg['model'] ?? 'lite');
$timeout = (float) ($cfg['timeout'] ?? 60);
if ($password === '') {
$this->utils->errorThrow('未配置讯飞星火 API 密钥请在「AI密钥管理」或 .env SPARK_API_PASSWORD 配置)');
}
if ($apiUrl === '') {
$this->utils->errorThrow('未配置讯飞星火接口地址');
}
if (empty($messages)) {
$this->utils->errorThrow('AI 对话消息不能为空');
}
$model = (string) ($options['model'] ?? $defaultModel);
$body = [
'model' => $model,
'messages' => array_values($messages),
];
// 可选参数透传,便于业务微调温度等
foreach (['temperature', 'max_tokens', 'top_p', 'stream'] as $key) {
if (array_key_exists($key, $options)) {
$body[$key] = $options[$key];
}
}
// 默认关闭流式,业务侧解析整包 JSON 更简单
if (!isset($body['stream'])) {
$body['stream'] = false;
}
try {
$client = new Client([
'timeout' => $timeout,
'connect_timeout' => min(10.0, $timeout),
]);
$response = $client->request('POST', $apiUrl, [
'headers' => [
'Authorization' => 'Bearer ' . $password,
'Content-Type' => 'application/json',
],
'json' => $body,
]);
$raw = json_decode((string) $response->getBody(), true);
if (!is_array($raw)) {
$this->utils->errorThrow('讯飞星火返回非 JSON');
}
$content = (string) data_get($raw, 'choices.0.message.content', '');
if ($content === '') {
$errMsg = (string) ($raw['error']['message'] ?? $raw['message'] ?? '');
$this->utils->errorThrow($errMsg !== '' ? ('讯飞星火调用失败:' . $errMsg) : '讯飞星火未返回有效内容');
}
$usage = is_array($raw['usage'] ?? null) ? $raw['usage'] : [];
return new AiChatResult(
$content,
$raw,
$this->provider(),
$model,
$usage,
(int) ($cfg['api_key_id'] ?? 0)
);
} catch (Throwable $e) {
if ($e instanceof GuzzleException) {
$this->utils->errorThrow('讯飞星火请求失败:' . $e->getMessage());
}
throw $e;
}
}
}

View File

@@ -312,12 +312,13 @@ class CodeGenerationService
$v['default'] = (strpos($v['type'], 'INT') || in_array($v['type'], self::INT_TYPE)) ? 'DEFAULT ' . 0 : 'DEFAULT ' . '\'\'';
}
// 字段名加反引号,避免非法标识符直接破坏 CREATE TABLE 语法
$col = '`' . str_replace('`', '', (string) $v['name']) . '`';
$comment = str_replace("'", "\\'", (string) $v['comment']);
if (in_array($v['type'], self::TEXT_TYPE)) {
// 编写sql语句
$sql .= " {$v['name']} {$v['type']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
$sql .= " {$col} {$v['type']} {$v['not_null']} NULL COMMENT '{$comment}', ";
} else {
// 编写sql语句
$sql .= " {$v['name']} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$v['comment']}', ";
$sql .= " {$col} {$v['type']}({$v['type_length']}) {$v['default']} {$v['not_null']} NULL COMMENT '{$comment}', ";
}
}

20
config/ai.php Normal file
View File

@@ -0,0 +1,20 @@
<?php
/**
* AI 供应商环境回落配置(库内未配置密钥/地址时使用)
*/
return [
'default_provider' => env('AI_DEFAULT_PROVIDER', 'deepseek'),
'spark' => [
'api_url' => env('SPARK_API_URL', 'https://spark-api-open.xf-yun.com/v1/chat/completions'),
'password' => env('SPARK_API_PASSWORD', ''),
'model' => env('SPARK_API_MODEL', 'lite'),
'timeout' => (float) env('SPARK_API_TIMEOUT', 60),
],
'deepseek' => [
'api_url' => env('DEEPSEEK_API_URL', 'https://api.deepseek.com/v1/chat/completions'),
'api_key' => env('DEEPSEEK_API_KEY', ''),
'model' => env('DEEPSEEK_API_MODEL', 'deepseek-chat'),
'timeout' => (float) env('DEEPSEEK_API_TIMEOUT', 60),
],
];

View File

@@ -397,7 +397,7 @@ if(!function_exists('ase_encode')) {
function ase_encode($str)
{
if (empty($str)) return $str;
return base64_encode(\Illuminate\Support\Str::random(config('ase.len')). base64_encode($str));
return base64_encode(\Illuminate\Support\Str::random(config('nl.ase_len', 30)). base64_encode($str));
}
}
@@ -411,7 +411,7 @@ if (!function_exists('ase_decode')) {
{
if (empty($str)) return $str?? '';
$result = base64_decode($str);
$result = base64_decode(substr($result, config('ase.len')));
$result = base64_decode(substr($result, config('nl.ase_len', 30)));
$pattern = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\xFF]|[\x{E000}-\x{F8FF}]/u';
if (preg_match($pattern, $result)) return $str;
return $result;

View File

@@ -24,17 +24,26 @@ return [
'jwt' => [
'secret' => env('JWT_SECRET', 'nl_admin_jwt_secret_key_change_me_32b'),
],
/*
* 库内敏感字段 AES 密钥(密文前缀 nl_ase_256_读取兼容历史 ***
* 对应 .envENCRYPT_KEY
*/
'encrypt_key' => env('ENCRYPT_KEY', ''),
/*
* 传输层 ase_encode / ase_decode 随机盐长度(与历史 helpers 兼容)
*/
'ase_len' => (int) env('ASE_LEN', 30),
/*
* redis配置
*/
'redis' => [
'jwt' => 'xk_login_',
'jwt' => 'nl_login_',
'jwt_ttl' => 360000,
// 'jwt_ttl' => 360,
'email' => 'xk_email_',
'phone' => 'xk_phone_',
'login_out_key' => 'xk_login_out_',
'menu_key' => 'xk_menu_',
'email' => 'nl_email_',
'phone' => 'nl_phone_',
'login_out_key' => 'nl_login_out_',
'menu_key' => 'nl_menu_',
],
/*
* api白名单
@@ -49,7 +58,7 @@ return [
* 阿里云oss配置
*/
'oss' => [
'redis_key' => 'xk_oss_url_',
'redis_key' => 'nl_oss_url_',
'ali' => [
'accessKeyId' => env('OSS_ACCESS_KEY_ID'),
'accessKeySecret' => env('OSS_ACCESS_KEY_SECRET'),

View File

@@ -33,6 +33,7 @@ Route::group([], function () {
'menu' => \App\Http\Controllers\Api\MenuController::class, // 菜单管理
'upload' => \App\Http\Controllers\Api\UploadController::class, // 上传文件
'database' => \App\Http\Controllers\Api\DatabaseController::class, // 数据库管理
'ai-config' => \App\Http\Controllers\Api\AiConfigController::class, // AI 配置(平台/模型/密钥)
// 需要登录的路由生成地址
]);
});