Files
2026-06-03 14:15:55 +08:00

202 lines
7.9 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.
const Router = require('koa-router');
const fetch = require('node-fetch');
const { query } = require('../config/database');
const { toRelativeUrl } = require('../utils/image-url');
const { requireStaffAuth } = require('../middleware/auth');
const { sanitizeKeyword, sanitizeImageUrl, sanitizeImageBase64, makeCacheKey, LRU, TokenBucket } = require('../utils/ai-utils');
require('dotenv').config();
const router = new Router();
const AI_API_KEY = process.env.DASHSCOPE_API_KEY;
const AI_API_URL = 'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions';
if (!AI_API_KEY) {
console.error('DASHSCOPE_API_KEY is not set - AI features will fail')
}
const cache = new LRU(200, 5 * 60 * 1000)
const bucket = new TokenBucket(20, 1)
async function callQwen(model, body, timeoutMs) {
const response = await fetch(AI_API_URL, {
method: 'POST',
headers: {
'Authorization': `Bearer ${AI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(body),
timeout: timeoutMs || 30000
})
if (!response.ok) {
const err = new Error(`AI 服务调用失败: ${response.status}`)
err.status = response.status
err.body = await response.text()
throw err
}
return response.json()
}
function mapAIError(err) {
if (err.status === 401) return { code: 500, message: 'API Key 无效,请检查密钥配置' }
if (err.status === 403) return { code: 500, message: 'API 调用被拒绝,请检查账户权限' }
if (err.status === 429) return { code: 500, message: 'API 调用次数超限,请稍后重试' }
if (err.status === 503) return { code: 500, message: 'AI 服务暂时不可用,请稍后重试' }
if (err.message && err.message.includes('timeout')) return { code: 503, message: 'AI 服务响应超时,请稍后重试' }
if (err.message && err.message.includes('ENOTFOUND')) return { code: 503, message: '无法连接到 AI 服务,请检查网络' }
if (err.message && err.message.includes('ECONNRESET')) return { code: 503, message: 'AI 服务连接中断,请稍后重试' }
return { code: 503, message: 'AI 服务异常,请稍后重试' }
}
function tryParseJSON(text) {
if (!text) return null
const md = text.match(/```(?:json)?\s*([\s\S]*?)```/)
const jsonStr = md ? md[1].trim() : (text.match(/\{[\s\S]*\}/)?.[0] || text)
try { return JSON.parse(jsonStr) } catch { return null }
}
router.post('/generate-product', requireStaffAuth(), async (ctx) => {
if (!AI_API_KEY) {
ctx.status = 500
ctx.body = { code: 500, message: 'AI 功能未配置(缺少 DASHSCOPE_API_KEY' }
return
}
if (!bucket.take()) {
ctx.status = 429
ctx.body = { code: 429, message: 'AI 调用过于频繁,请稍后重试' }
return
}
const { imageUrl, keywords } = ctx.request.body || {}
const kw = sanitizeKeyword(keywords)
if (kw.error) { ctx.status = 400; ctx.body = { code: 400, message: kw.error }; return }
const url = sanitizeImageUrl(imageUrl)
if (url.error) { ctx.status = 400; ctx.body = { code: 400, message: url.error }; return }
if (!kw.value && !url.value) { ctx.status = 400; ctx.body = { code: 400, message: '请提供图片或关键词' }; return }
const cacheKey = makeCacheKey('gen', { kw: kw.value, url: url.value })
const hit = cache.get(cacheKey)
if (hit) {
ctx.body = { code: 200, message: '生成成功', data: hit, cached: true }
return
}
let prompt = '你是一个专业的便利店商品管理助手。'
if (url.value) prompt += `\n请分析这张商品图片:${url.value}`
if (kw.value) prompt += `\n关键词:${kw.value}`
prompt += `
请生成商品的详细信息,返回JSON格式,不要包含其他内容:
{
"name": "商品名称(简洁明了,2-10字)",
"category": "商品分类(请从以下选择:饮料,零食,日用品,食品,生鲜,烟酒,其他)",
"description": "商品详细描述(50-100字,突出产品特点)",
"suggestedPrice": 建议售价(数字)
}`
try {
const data = await callQwen('qwen3.5-flash', {
model: 'qwen3.5-flash',
messages: [{ role: 'user', content: prompt }],
temperature: 0.7,
max_tokens: 500
}, 30000)
const aiResponse = data.choices?.[0]?.message?.content
if (!aiResponse) { ctx.status = 500; ctx.body = { code: 500, message: 'AI 服务返回为空' }; return }
const productInfo = tryParseJSON(aiResponse)
if (!productInfo) { ctx.status = 500; ctx.body = { code: 500, message: '无法解析 AI 响应格式' }; return }
cache.set(cacheKey, productInfo)
ctx.body = { code: 200, message: '生成成功', data: productInfo }
} catch (error) {
const mapped = mapAIError(error)
ctx.status = mapped.code
ctx.body = mapped
}
});
router.post('/recognize-product', requireStaffAuth(), async (ctx) => {
if (!AI_API_KEY) {
ctx.status = 500
ctx.body = { code: 500, message: 'AI 功能未配置(缺少 DASHSCOPE_API_KEY' }
return
}
if (!bucket.take()) {
ctx.status = 429
ctx.body = { code: 429, message: 'AI 调用过于频繁,请稍后重试' }
return
}
const { imageBase64, imageUrl } = ctx.request.body || {}
let inputImageUrl = ''
if (imageBase64) {
const b = sanitizeImageBase64(imageBase64)
if (b.error) { ctx.status = 400; ctx.body = { code: 400, message: b.error }; return }
inputImageUrl = `data:image/jpeg;base64,${b.value}`
}
if (!inputImageUrl && imageUrl) {
const u = sanitizeImageUrl(imageUrl)
if (u.error) { ctx.status = 400; ctx.body = { code: 400, message: u.error }; return }
inputImageUrl = u.value
}
if (!inputImageUrl) { ctx.status = 400; ctx.body = { code: 400, message: '请提供商品图片' }; return }
const cacheKey = makeCacheKey('recog', { img: inputImageUrl.slice(0, 4096) })
const hit = cache.get(cacheKey)
if (hit) {
ctx.body = { code: 200, message: '识别成功', data: hit, cached: true }
return
}
const prompt = `你是一个专业的便利店商品识别助手。请分析这张商品图片,识别出商品信息。
请返回JSON格式的商品信息,只返回一个最可能的商品:
{
"name": "商品名称(根据图片识别,如果无法确定则返回空字符串)",
"category": "商品分类(从以下选择:饮料,零食,日用品,食品,生鲜,烟酒,其他,如果无法确定则返回空字符串)",
"description": "商品描述(根据图片识别,突出产品特点,如果无法确定则返回空字符串)",
"suggestedPrice": 数字(根据市场价估算,如果无法确定则返回0),
"confidence": 0到1之间的数字(识别置信度)
}`;
try {
const data = await callQwen('qwen3.5-omni', {
model: 'qwen3.5-omni',
messages: [{
role: 'user',
content: [
{ type: 'image_url', image_url: { url: inputImageUrl } },
{ type: 'text', text: prompt }
]
}],
temperature: 0.3,
max_tokens: 500
}, 60000)
const aiResponse = data.choices?.[0]?.message?.content
if (!aiResponse) { ctx.status = 500; ctx.body = { code: 500, message: 'AI 服务返回为空' }; return }
const productInfo = tryParseJSON(aiResponse)
if (!productInfo) { ctx.status = 500; ctx.body = { code: 500, message: '无法解析 AI 响应格式' }; return }
const keyword = (productInfo.name || '').slice(0, 50)
let matchedGoods = []
if (keyword) {
const dbResult = await query(
'SELECT id, name, price, unit, category_id, images, stock, pricing_type, is_hot, is_new, description FROM goods WHERE name LIKE ? LIMIT 20',
[`%${keyword}%`]
)
matchedGoods = dbResult
}
const { processGoodsImages } = require('../utils/image-url')
matchedGoods = processGoodsImages(matchedGoods)
const result = { aiInfo: productInfo, matchedGoods }
cache.set(cacheKey, result)
ctx.body = { code: 200, message: '识别成功', data: result }
} catch (error) {
const mapped = mapAIError(error)
ctx.status = mapped.code
ctx.body = mapped
}
});
module.exports = router.routes();