first commit
This commit is contained in:
19
src/config.js
Normal file
19
src/config.js
Normal file
@@ -0,0 +1,19 @@
|
||||
require("dotenv").config();
|
||||
|
||||
module.exports = {
|
||||
port: parseInt(process.env.PORT, 10) || 3000,
|
||||
|
||||
wechat: {
|
||||
appid: process.env.WECHAT_APPID || "",
|
||||
secret: process.env.WECHAT_SECRET || "",
|
||||
},
|
||||
|
||||
deepseek: {
|
||||
apiKey: process.env.DEEPSEEK_API_KEY || "",
|
||||
baseUrl: process.env.DEEPSEEK_BASE_URL || "https://api.deepseek.com",
|
||||
model: process.env.DEEPSEEK_MODEL || "deepseek-chat",
|
||||
},
|
||||
|
||||
jwtSecret: process.env.JWT_SECRET || "dev-secret-change-in-production",
|
||||
jwtExpiresIn: "7d",
|
||||
};
|
||||
40
src/index.js
Normal file
40
src/index.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const http = require("http");
|
||||
const express = require("express");
|
||||
const cors = require("cors");
|
||||
const { WebSocketServer } = require("ws");
|
||||
const config = require("./config");
|
||||
const authRoutes = require("./routes/auth");
|
||||
const posterRoutes = require("./routes/poster");
|
||||
const { handleConnection } = require("./wsHandler");
|
||||
|
||||
const app = express();
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
// HTTP 路由
|
||||
app.use(authRoutes);
|
||||
app.use(posterRoutes);
|
||||
|
||||
// 健康检查
|
||||
app.get("/", (req, res) => {
|
||||
res.json({ status: "ok", service: "周公解梦 API" });
|
||||
});
|
||||
|
||||
const server = http.createServer(app);
|
||||
|
||||
// WebSocket 服务器
|
||||
const wss = new WebSocketServer({ server, path: "/chat" });
|
||||
|
||||
wss.on("connection", (ws, req) => {
|
||||
handleConnection(ws, req);
|
||||
});
|
||||
|
||||
server.listen(config.port, () => {
|
||||
console.log(`服务已启动: http://localhost:${config.port}`);
|
||||
console.log(`WebSocket: ws://localhost:${config.port}/chat`);
|
||||
console.log("接口列表:");
|
||||
console.log(" GET /login?code=xxx — 微信登录");
|
||||
console.log(" GET /generate-post?record_id= — 生成分享海报");
|
||||
console.log(" WSS /chat — 解梦对话");
|
||||
});
|
||||
28
src/routes/auth.js
Normal file
28
src/routes/auth.js
Normal file
@@ -0,0 +1,28 @@
|
||||
const { Router } = require("express");
|
||||
const jwt = require("jsonwebtoken");
|
||||
const config = require("../config");
|
||||
const { code2session } = require("../services/wechat");
|
||||
|
||||
const router = Router();
|
||||
|
||||
router.get("/login", async (req, res) => {
|
||||
try {
|
||||
const { code } = req.query;
|
||||
if (!code) {
|
||||
return res.status(400).json({ error: "缺少 code 参数" });
|
||||
}
|
||||
|
||||
const { openid } = await code2session(code);
|
||||
|
||||
const token = jwt.sign({ openid }, config.jwtSecret, {
|
||||
expiresIn: config.jwtExpiresIn,
|
||||
});
|
||||
|
||||
res.json({ access_token: token });
|
||||
} catch (err) {
|
||||
console.error("登录失败:", err.message);
|
||||
res.status(500).json({ error: "登录失败,请稍后重试" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
96
src/routes/poster.js
Normal file
96
src/routes/poster.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const { Router } = require("express");
|
||||
const sharp = require("sharp");
|
||||
const store = require("../store");
|
||||
|
||||
const router = Router();
|
||||
|
||||
const POSTER_WIDTH = 750;
|
||||
const POSTER_HEIGHT = 1200;
|
||||
|
||||
function wrapText(text, maxCharsPerLine) {
|
||||
const lines = [];
|
||||
let current = "";
|
||||
for (const ch of text) {
|
||||
if (ch === "\n") {
|
||||
lines.push(current);
|
||||
current = "";
|
||||
} else if (current.length >= maxCharsPerLine) {
|
||||
lines.push(current);
|
||||
current = ch;
|
||||
} else {
|
||||
current += ch;
|
||||
}
|
||||
}
|
||||
if (current) lines.push(current);
|
||||
return lines;
|
||||
}
|
||||
|
||||
function buildPosterSvg(dream, interpretation) {
|
||||
const dreamLines = wrapText(`梦:${dream}`, 18).slice(0, 10);
|
||||
const interpLines = wrapText(interpretation, 18);
|
||||
|
||||
const titleY = 80;
|
||||
const dreamCardY = 140;
|
||||
const interpCardY = 400;
|
||||
const lineHeight = 36;
|
||||
const dreamCardH = dreamLines.length * lineHeight + 80;
|
||||
const interpCardH = interpLines.length * lineHeight + 80;
|
||||
|
||||
const dreamTextSvg = dreamLines
|
||||
.map((l, i) => `<text x="48" y="${48 + i * lineHeight}" font-size="28" fill="#333">${escapeXml(l)}</text>`)
|
||||
.join("\n");
|
||||
|
||||
const interpTextSvg = interpLines
|
||||
.map((l, i) => `<text x="48" y="${48 + i * lineHeight}" font-size="28" fill="#333">${escapeXml(l)}</text>`)
|
||||
.join("\n");
|
||||
|
||||
const footerY = interpCardY + interpCardH + 60;
|
||||
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="${POSTER_WIDTH}" height="${Math.max(POSTER_HEIGHT, footerY + 80)}">
|
||||
<defs>
|
||||
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stop-color="#1a1a2e"/>
|
||||
<stop offset="100%" stop-color="#16213e"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<text x="375" y="${titleY}" text-anchor="middle" font-size="48" font-weight="bold" fill="#e0c77c">周公解梦</text>
|
||||
<text x="375" y="${titleY + 32}" text-anchor="middle" font-size="20" fill="#a0a0b0">— AI 智能梦境解析 —</text>
|
||||
<rect x="24" y="${dreamCardY}" width="702" height="${dreamCardH}" rx="16" fill="#ffffff" opacity="0.95"/>
|
||||
<text x="48" y="${dreamCardY + 38}" font-size="24" font-weight="bold" fill="#1a1a2e">梦境描述</text>
|
||||
${dreamTextSvg}
|
||||
<rect x="24" y="${interpCardY}" width="702" height="${interpCardH}" rx="16" fill="#ffffff" opacity="0.95"/>
|
||||
<text x="48" y="${interpCardY + 38}" font-size="24" font-weight="bold" fill="#1a1a2e">解梦分析</text>
|
||||
${interpTextSvg}
|
||||
<text x="375" y="${footerY}" text-anchor="middle" font-size="22" fill="#606070">— 周公解梦 · AI 智能解析 —</text>
|
||||
</svg>`;
|
||||
}
|
||||
|
||||
function escapeXml(s) {
|
||||
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
||||
}
|
||||
|
||||
router.get("/generate-post", async (req, res) => {
|
||||
try {
|
||||
const { record_id } = req.query;
|
||||
if (!record_id) {
|
||||
return res.status(400).json({ error: "缺少 record_id 参数" });
|
||||
}
|
||||
|
||||
const record = store.getRecord(record_id);
|
||||
if (!record) {
|
||||
return res.status(404).json({ error: "记录不存在" });
|
||||
}
|
||||
|
||||
const svg = buildPosterSvg(record.dream, record.interpretation);
|
||||
const png = await sharp(Buffer.from(svg)).png().toBuffer();
|
||||
|
||||
res.set("Content-Type", "image/png");
|
||||
res.send(png);
|
||||
} catch (err) {
|
||||
console.error("生成海报失败:", err.message);
|
||||
res.status(500).json({ error: "生成海报失败" });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
68
src/services/deepseek.js
Normal file
68
src/services/deepseek.js
Normal file
@@ -0,0 +1,68 @@
|
||||
const axios = require("axios");
|
||||
const config = require("../config");
|
||||
|
||||
const SYSTEM_PROMPT = `你是一位专业的梦境解析师,精通《周公解梦》和中国传统文化中的解梦理论。用户会向你描述他们的梦境,请你:
|
||||
|
||||
1. 首先提取梦境中的关键元素和符号
|
||||
2. 结合传统解梦理论进行分析
|
||||
3. 给出通俗易懂、温暖鼓励的解析
|
||||
4. 最后给出一些积极的生活建议
|
||||
|
||||
注意:
|
||||
- 保持理性客观,不要散布迷信
|
||||
- 语言优美流畅,富有文采
|
||||
- 如果梦境内容负面,要给予正向引导和安抚
|
||||
- 控制在300字以内`;
|
||||
|
||||
async function* streamDreamInterpretation(dream) {
|
||||
const url = `${config.deepseek.baseUrl}/v1/chat/completions`;
|
||||
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
model: config.deepseek.model,
|
||||
messages: [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: `我梦到了:${dream}` },
|
||||
],
|
||||
stream: true,
|
||||
temperature: 0.8,
|
||||
max_tokens: 1024,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.deepseek.apiKey}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
responseType: "stream",
|
||||
timeout: 60000,
|
||||
}
|
||||
);
|
||||
|
||||
const stream = response.data;
|
||||
let buffer = "";
|
||||
|
||||
for await (const chunk of stream) {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || !trimmed.startsWith("data:")) continue;
|
||||
|
||||
const data = trimmed.slice(5).trim();
|
||||
if (data === "[DONE]") return;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
const content = parsed.choices?.[0]?.delta?.content;
|
||||
if (content) yield content;
|
||||
} catch {
|
||||
// 忽略无法解析的 chunk
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { streamDreamInterpretation };
|
||||
18
src/services/wechat.js
Normal file
18
src/services/wechat.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const axios = require("axios");
|
||||
const config = require("../config");
|
||||
|
||||
async function code2session(code) {
|
||||
const { appid, secret } = config.wechat;
|
||||
const url = "https://api.weixin.qq.com/sns/jscode2session";
|
||||
const { data } = await axios.get(url, {
|
||||
params: { appid, secret, js_code: code, grant_type: "authorization_code" },
|
||||
});
|
||||
|
||||
if (data.errcode) {
|
||||
throw new Error(`微信登录失败: ${data.errmsg} (errcode=${data.errcode})`);
|
||||
}
|
||||
|
||||
return { openid: data.openid, sessionKey: data.session_key };
|
||||
}
|
||||
|
||||
module.exports = { code2session };
|
||||
22
src/store.js
Normal file
22
src/store.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const { v4: uuidv4 } = require("uuid");
|
||||
|
||||
// 内存存储:record_id → { dream, interpretation, userId, createdAt }
|
||||
const records = new Map();
|
||||
|
||||
module.exports = {
|
||||
saveRecord(userId, dream, interpretation) {
|
||||
const id = uuidv4();
|
||||
records.set(id, {
|
||||
id,
|
||||
userId,
|
||||
dream,
|
||||
interpretation,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
return id;
|
||||
},
|
||||
|
||||
getRecord(id) {
|
||||
return records.get(id) || null;
|
||||
},
|
||||
};
|
||||
83
src/wsHandler.js
Normal file
83
src/wsHandler.js
Normal file
@@ -0,0 +1,83 @@
|
||||
const jwt = require("jsonwebtoken");
|
||||
const config = require("./config");
|
||||
const store = require("./store");
|
||||
const { streamDreamInterpretation } = require("./services/deepseek");
|
||||
|
||||
function handleConnection(ws, req) {
|
||||
// 从 url 参数或 header 中提取 token
|
||||
const url = new URL(req.url, "http://localhost");
|
||||
const token = url.searchParams.get("token") || req.headers["authorization"];
|
||||
|
||||
let userId = null;
|
||||
try {
|
||||
const payload = jwt.verify(token, config.jwtSecret);
|
||||
userId = payload.openid;
|
||||
} catch {
|
||||
ws.send(
|
||||
JSON.stringify({ act: "error", message: "请先登录后再解梦" })
|
||||
);
|
||||
ws.close(4001, "未授权");
|
||||
return;
|
||||
}
|
||||
|
||||
let fullInterpretation = "";
|
||||
|
||||
ws.on("message", async (raw) => {
|
||||
let msg;
|
||||
try {
|
||||
msg = JSON.parse(raw.toString());
|
||||
} catch {
|
||||
ws.send(JSON.stringify({ act: "error", message: "消息格式错误" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.act !== "start_generate") return;
|
||||
|
||||
const { message: dream, template_name } = msg.payload || {};
|
||||
if (!dream || !dream.trim()) {
|
||||
ws.send(JSON.stringify({ act: "error", message: "请填写梦境内容" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (template_name !== "jiemeng") {
|
||||
ws.send(JSON.stringify({ act: "error", message: "不支持的模板" }));
|
||||
return;
|
||||
}
|
||||
|
||||
fullInterpretation = "";
|
||||
|
||||
try {
|
||||
for await (const chunk of streamDreamInterpretation(dream.trim())) {
|
||||
fullInterpretation += chunk;
|
||||
ws.send(JSON.stringify({ act: "answer", message: chunk }));
|
||||
}
|
||||
|
||||
const recordId = store.saveRecord(
|
||||
userId,
|
||||
dream.trim(),
|
||||
fullInterpretation
|
||||
);
|
||||
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
act: "answer_finish",
|
||||
payload: { record_id: recordId },
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
console.error("DeepSeek 调用失败:", err.message);
|
||||
ws.send(
|
||||
JSON.stringify({
|
||||
act: "error",
|
||||
message: "AI 解析服务暂时不可用,请稍后重试",
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("close", () => {
|
||||
// 清理工作(如有需要)
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { handleConnection };
|
||||
Reference in New Issue
Block a user