Files
Drsakura 13d67c804f feat: 多用户权限体系、AI 助手、SKU 目录与合同清洗(开源准备)
本地长期开发成果的整体入库快照,包含后端鉴权/权限、AI 接入、合同解析、
SKU 目录等模块,以及前端相应页面与样式改版。

后端
- 新增鉴权与会话:scrypt 密码哈希、HttpOnly cookie(happy_sid)、登录失败锁定
- 新增「角色 → 权限组 → 权限点」三级权限模型与审计日志
- 新增 AI 提供商接入与预设(兼容 OpenAI 协议,内置 DeepSeek / 通义千问等)
- 新增合同解析、合同文件保留策略、客户归属与访问控制
- 新增 SKU 目录、组织品牌、新客开发、系统日志等路由

前端
- 新增登录页与用户 / 权限组 / 系统日志管理页
- 新增客户详情、客户公海、询盘详情、报价编辑器、合同清洗中心
- 新增 AI 配置、组织品牌、包装参数、SKU 选择器等组件
- 新增英文大写金额、未保存改动拦截、抽屉与骨架屏
- 新增 stage1 样式层;产品、供应商、订单等页面改版
- EmbeddedSku 页面并入现有产品体系后删除

开源准备
- 新增 AGPL-3.0 LICENSE
- README:移除未启用的 SQLCipher 加密声明(改为如实的「已知限制」)、
  补充环境变量说明、许可证由「私有项目」改为 AGPL-3.0
- 移除历史遗留的空文件 _index_backup.txt
- 文档中的项目路径更新为 happy-export
2026-09-17 14:53:02 +08:00

213 lines
8.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 智能配置:多模型接入、连通性测试、模型清单、用途分配。
* 挂载点:/api/ai
*
* 权限:
* - 接入配置的读与写都要 `ai.manage`(配置里有 base_url 与密钥打码,属系统管理面);
* - `POST /api/ai/chat` 只要登录 —— 它是给所有用户用的对话能力,不是管理动作。
*
* 多模型并行(Wayne 2026-09-15):
* 可以同时接多家、每家多个模型,然后按「用途」把不同模型派给不同活 ——
* 合同解析用长文本强的,图片识别用多模态的,日常对话用便宜的。
*/
const express = require('express');
const auth = require('../lib/auth');
const ai = require('../lib/aiProviders');
const { MODES, PRESETS, PURPOSES, PURPOSE_KEYS } = require('../lib/aiPresets');
const router = express.Router();
function fail(res, error) {
const message = error?.message || '请求处理失败';
const status = error?.statusCode
|| (/不能为空|不正确|不存在|无效|格式|只能|超过/.test(message) ? 400 : 500);
res.status(status).json({ error: message });
}
/** GET /api/ai/config —— 一次拿全:预设、兼容模式、用途、已接入的服务商、用途分配 */
router.get('/config', auth.requirePermission('ai.manage'), (req, res) => {
try {
res.json({
presets: PRESETS,
modes: MODES,
purposes: PURPOSES,
providers: ai.listProviders().map(ai.publicProvider),
assignments: ai.listAssignments(),
overview: ai.overview()
});
} catch (error) {
fail(res, error);
}
});
/** GET /api/ai/status —— 轻量状态:小皮这类普通用户界面用(不含任何配置细节) */
router.get('/status', auth.requireAuth, (req, res) => {
try {
const resolved = ai.resolveAssignment('assistant_chat');
res.json({
configured: Boolean(resolved),
assistant: resolved ? { provider: resolved.provider.name, model: resolved.model } : null
});
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers —— 新增一家模型服务 */
router.post('/providers', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {});
auth.audit({
username: req.user.username,
action: 'ai_provider_create',
detail: `接入模型服务「${provider.name}」(${provider.base_url} · ${provider.mode})`,
req
});
res.status(201).json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** PATCH /api/ai/providers/:id —— 改配置(密钥不回传,省略即保持原值) */
router.patch('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const provider = ai.upsertProvider(req.body || {}, req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_update',
detail: `更新接入「${provider.name}」:${provider.enabled === false ? '停用' : '启用'} · 模型 ${provider.models.length} 个`,
req
});
res.json({ provider: ai.publicProvider(provider) });
} catch (error) {
fail(res, error);
}
});
/** DELETE /api/ai/providers/:id —— 移除接入(顺手清掉指向它的用途分配) */
router.delete('/providers/:id', auth.requirePermission('ai.manage'), (req, res) => {
try {
const result = ai.removeProvider(req.params.id);
auth.audit({
username: req.user.username,
action: 'ai_provider_delete',
detail: `移除接入「${result.removed.name}」${result.cleared_assignments ? `(同时清掉 ${result.cleared_assignments} 处用途分配)` : ''}`,
req
});
res.json(result);
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/test —— 连通性测试(真发请求) */
router.post('/providers/:id/test', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.testProvider(provider);
ai.rememberTest(provider.id, {
ok: result.ok,
at: result.at,
latency_ms: result.latency_ms,
detail: result.detail
});
auth.audit({
username: req.user.username,
action: 'ai_provider_test',
detail: `连通测试「${provider.name}」:${result.ok ? '通过' : '失败'} · ${result.detail}`,
req,
ok: result.ok ? 1 : 0
});
res.json({ result, provider: ai.publicProvider(ai.getProvider(provider.id)) });
} catch (error) {
fail(res, error);
}
});
/** POST /api/ai/providers/:id/models —— 拉取可用模型(save=1 时顺便存进该接入) */
router.post('/providers/:id/models', auth.requirePermission('ai.manage'), async (req, res) => {
try {
const provider = ai.getProvider(req.params.id);
if (!provider) throw Object.assign(new Error('该模型接入不存在'), { statusCode: 400 });
const result = await ai.fetchModels(provider);
if (!result.ok && !result.models.length) {
return res.status(400).json({ error: result.error || '未能读取模型清单' });
}
let saved = null;
if (String(req.body?.save || '') === '1' && result.models.length) {
saved = ai.publicProvider(ai.upsertProvider({ models: result.models }, provider.id));
}
res.json({ models: result.models, source: result.source, detail: result.detail, provider: saved });
} catch (error) {
fail(res, error);
}
});
/**
* PUT /api/ai/assignments —— 保存「用途 → 模型」分配。
* 传 { assignments: { purpose: { provider_id, model } | null } },整体替换。
*/
router.put('/assignments', auth.requirePermission('ai.manage'), (req, res) => {
try {
const input = req.body?.assignments;
if (input === undefined || input === null || typeof input !== 'object' || Array.isArray(input)) {
throw Object.assign(new Error('分配内容格式不正确'), { statusCode: 400 });
}
const providers = ai.listProviders();
const next = {};
for (const [purpose, value] of Object.entries(input)) {
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
if (value === null || value === undefined || value === '') continue;
const provider = providers.find(item => item.id === String(value.provider_id));
if (!provider) throw Object.assign(new Error(`用途「${purpose}」指向的模型接入不存在`), { statusCode: 400 });
const model = String(value.model || '').trim();
if (!model) continue;
next[purpose] = { provider_id: provider.id, model };
}
ai.saveAssignments(next);
auth.audit({
username: req.user.username,
action: 'ai_assignment_update',
detail: `用途分配:${Object.entries(next).map(([k, v]) => `${k}→${v.model}`).join(',') || '(全部清空)'}`,
req
});
res.json({ assignments: next, overview: ai.overview() });
} catch (error) {
fail(res, error);
}
});
/**
* POST /api/ai/chat —— 按用途发起一次真实对话(试跑 / 小皮内部使用)。
* body: { prompt | messages, purpose, temperature }
*/
router.post('/chat', auth.requireAuth, async (req, res) => {
try {
const purpose = String(req.body?.purpose || 'assistant_chat');
if (!PURPOSE_KEYS.includes(purpose)) {
throw Object.assign(new Error(`未知的用途:${purpose}`), { statusCode: 400 });
}
const messages = Array.isArray(req.body?.messages) && req.body.messages.length
? req.body.messages.map(item => ({ role: item.role === 'assistant' ? 'assistant' : 'user', content: String(item.content || '') }))
: [{ role: 'user', content: String(req.body?.prompt || '').trim() }];
if (!messages[0].content) throw Object.assign(new Error('请输入内容'), { statusCode: 400 });
const result = await ai.chatByPurpose(purpose, messages, {
temperature: req.body?.temperature
});
if (!result) {
return res.status(409).json({ error: '还没有为这个用途分配模型,请先在「设置 → 智能配置」里接入并分配' });
}
if (!result.ok) return res.status(502).json({ error: `${result.provider} 调用失败:${result.error}`, ...result });
res.json(result);
} catch (error) {
fail(res, error);
}
});
module.exports = router;