落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+4
-2
@@ -52,13 +52,15 @@ PG_PASSWORD=
|
||||
PG_SSLMODE=prefer
|
||||
CHECKPOINT_BACKEND=postgres
|
||||
|
||||
# Redis(建议 db1,前缀 inquiry_robot:;禁止 ytd:prod:)
|
||||
# Redis(建议逻辑库号 /1,键前缀 inquiry_robot:;禁止 ytd:prod:)
|
||||
# 说明:Redis 没有「库名 inquiry_robot」,只有数字 db;可读名靠 REDIS_KEY_PREFIX
|
||||
YTD_REDIS_HOST=10.206.0.14
|
||||
YTD_REDIS_PORT=6379
|
||||
YTD_REDIS_USERNAME=ytd_app
|
||||
REDIS_URL=redis://ytd_app:<密码>@10.206.0.14:6379/1
|
||||
REDIS_KEY_PREFIX=inquiry_robot:
|
||||
REDIS_REQUIRED=true
|
||||
REDIS_BACKEND=redis
|
||||
MESSAGE_STORE_BACKEND=auto
|
||||
|
||||
# 对象存储桶 inquiry_robot(禁止 ytd-inquiry)
|
||||
OBJECT_STORAGE_ENDPOINT_URL=http://127.0.0.1:9000
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!inquiry-backend/.env.production
|
||||
!inquiry-backend/.env.development
|
||||
*.pem
|
||||
*.key
|
||||
secrets/
|
||||
@@ -9,6 +11,9 @@ secrets/
|
||||
**/application-secret.yml
|
||||
# 含生产明文密钥,禁止进仓(见 prompt/00-INDEX.md)
|
||||
prompt/03-配置与密钥清单.md
|
||||
# 现网拉取只读区(含 .env / secure 明文)
|
||||
_ref/
|
||||
_ref/**
|
||||
|
||||
# Python
|
||||
.venv/
|
||||
|
||||
+10
-1
@@ -7,4 +7,13 @@ node server.cjs
|
||||
curl http://127.0.0.1:8913/health
|
||||
```
|
||||
|
||||
并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。
|
||||
转发联调(智能体 HTTP 需已起):
|
||||
|
||||
```text
|
||||
curl -X POST http://127.0.0.1:8913/forward -H "Content-Type: application/json" -d "{\"sender_id\":\"userid\",\"message_id\":\"m1\",\"content\":\"hi\",\"chat_type\":\"group\"}"
|
||||
```
|
||||
|
||||
环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`。
|
||||
|
||||
并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。
|
||||
`long_connection.cjs`:启用时只跑帧循环/心跳壳(默认不连 Bot SDK);正式 SDK 只替换传输段。
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* AIBOT 长连接传输壳(无业务)。
|
||||
*
|
||||
* 本文件职责:
|
||||
* - 当 WECOM_AIBOT_ENABLED=true 时启动「帧循环」占位(默认只心跳日志,不连真 Bot SDK)
|
||||
* - 收到已规范化的入站帧时转发到同进程 forwardToAgent(由 server.cjs 注入)
|
||||
*
|
||||
* 禁止:写工单/六态/Graph;并存期勿与现网 8813 双连同一 Bot。
|
||||
* 正式 SDK 接入:替换 startLongConnectionLoop 内的 connect 段,保持只做传输。
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {boolean} opts.enabled
|
||||
* @param {(payloadText: string) => Promise<object>} opts.forwardToAgent
|
||||
* @param {() => boolean} [opts.shouldStop]
|
||||
*/
|
||||
function startLongConnectionLoop(opts) {
|
||||
const enabled = !!opts.enabled;
|
||||
const forwardToAgent = opts.forwardToAgent;
|
||||
const shouldStop = opts.shouldStop || (() => false);
|
||||
|
||||
if (!enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[aibot-long-connection] 未启用(WECOM_AIBOT_ENABLED=false),跳过帧循环壳");
|
||||
return { stop: () => {} };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"[aibot-long-connection] 帧循环壳已启动:当前不连企微 Bot SDK,仅心跳;" +
|
||||
"请确认现网桥已停,避免双连"
|
||||
);
|
||||
|
||||
let timer = null;
|
||||
const tick = () => {
|
||||
if (shouldStop()) {
|
||||
return;
|
||||
}
|
||||
// 心跳:证明传输壳在跑;无帧可转发
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("[aibot-long-connection] heartbeat shell (no SDK frame)");
|
||||
};
|
||||
timer = setInterval(tick, 60000);
|
||||
tick();
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
timer = null;
|
||||
}
|
||||
},
|
||||
/**
|
||||
* 测试注入:模拟 SDK 收到一帧已解析 JSON,只做转发。
|
||||
* @param {object|string} frame
|
||||
*/
|
||||
injectFrameForTest: async (frame) => {
|
||||
const text = typeof frame === "string" ? frame : JSON.stringify(frame);
|
||||
return forwardToAgent(text);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { startLongConnectionLoop };
|
||||
+117
-16
@@ -1,46 +1,142 @@
|
||||
/**
|
||||
* AIBOT 长连接桥(骨架)
|
||||
* AIBOT 长连接桥(骨架 + HTTP 转发)
|
||||
*
|
||||
* 本文件职责:仅做本机回环 HTTP 健康检查与转发壳;不拥有业务状态。
|
||||
* 端口:环境变量 AIBOT_BRIDGE_PORT(测试默认 8913),绑定 AIBOT_BRIDGE_BIND(默认 127.0.0.1)。
|
||||
* 禁止:并存期连接现网 Bot 长连接(现网 8813 已占用);禁止在本进程写工单/六态/Graph。
|
||||
* 启动:在 aibot-bridge 目录执行 node server.cjs
|
||||
* 本文件职责:
|
||||
* - /health 存活
|
||||
* - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound)
|
||||
* - 默认不连企微 Bot 长连接(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时仅打警告
|
||||
*
|
||||
* 禁止:在本进程写工单/六态/Graph。
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const http = require("http");
|
||||
const { URL } = require("url");
|
||||
const { startLongConnectionLoop } = require("./long_connection.cjs");
|
||||
|
||||
const port = Number(process.env.AIBOT_BRIDGE_PORT || 8913);
|
||||
const bind = process.env.AIBOT_BRIDGE_BIND || "127.0.0.1";
|
||||
const enabled = String(process.env.WECOM_AIBOT_ENABLED || "false").toLowerCase() === "true";
|
||||
const ingressUrl =
|
||||
process.env.WECOM_AIBOT_INGRESS_URL ||
|
||||
"http://127.0.0.1:8910/internal/aibot/inbound";
|
||||
const bridgeToken = process.env.WECOM_AIBOT_BRIDGE_TOKEN || "";
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
// 只处理短请求;真实 AIBOT 帧转发后续迭代。请求线程不做业务。
|
||||
if (req.url === "/health" || req.url === "/health/") {
|
||||
const body = JSON.stringify({
|
||||
function readBody(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks = [];
|
||||
req.on("data", (c) => chunks.push(c));
|
||||
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
||||
req.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
function sendJson(res, code, obj) {
|
||||
const body = JSON.stringify(obj);
|
||||
res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(body);
|
||||
}
|
||||
|
||||
function forwardToAgent(payloadText) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(ingressUrl);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
return;
|
||||
}
|
||||
const data = Buffer.from(payloadText, "utf8");
|
||||
const req = http.request(
|
||||
{
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port || 80,
|
||||
path: target.pathname + (target.search || ""),
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": data.length,
|
||||
"X-Aibot-Bridge-Token": bridgeToken,
|
||||
},
|
||||
timeout: 5000,
|
||||
},
|
||||
(res) => {
|
||||
const chunks = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () => {
|
||||
resolve({
|
||||
status: res.statusCode,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.on("timeout", () => {
|
||||
req.destroy();
|
||||
reject(new Error("ingress_timeout"));
|
||||
});
|
||||
req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
const server = http.createServer(async (req, res) => {
|
||||
const url = (req.url || "").split("?")[0];
|
||||
|
||||
if (req.method === "GET" && (url === "/health" || url === "/health/")) {
|
||||
sendJson(res, 200, {
|
||||
status: "UP",
|
||||
service: "aibot-bridge",
|
||||
bind,
|
||||
port,
|
||||
wecomAibotEnabled: enabled,
|
||||
ingressUrl,
|
||||
longConnectionShell: true,
|
||||
note: enabled
|
||||
? "enabled flag on — 仍须确认未与现网双连"
|
||||
: "scaffold: Bot 长连接默认关闭(并存期禁止再连)",
|
||||
? "enabled:长连接帧循环壳已起(默认无 Bot SDK);勿与现网双连"
|
||||
: "Bot 长连接默认关闭;可用 POST /forward 测转发",
|
||||
});
|
||||
res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(body);
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "application/json; charset=utf-8" });
|
||||
res.end(JSON.stringify({ error: "not_found", path: req.url }));
|
||||
// 测试/后续 Bot 回调:把 JSON 原样转到智能体(不做业务)
|
||||
if (req.method === "POST" && (url === "/forward" || url === "/forward/")) {
|
||||
try {
|
||||
const raw = await readBody(req);
|
||||
if (!raw) {
|
||||
sendJson(res, 400, { error: "empty_body" });
|
||||
return;
|
||||
}
|
||||
// 轻量校验 JSON
|
||||
JSON.parse(raw);
|
||||
const out = await forwardToAgent(raw);
|
||||
sendJson(res, 200, {
|
||||
status: "forwarded",
|
||||
ingressStatus: out.status,
|
||||
ingressBody: (() => {
|
||||
try {
|
||||
return JSON.parse(out.body);
|
||||
} catch (_) {
|
||||
return out.body;
|
||||
}
|
||||
})(),
|
||||
});
|
||||
} catch (e) {
|
||||
sendJson(res, 502, { error: "forward_failed", message: String(e && e.message ? e.message : e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
sendJson(res, 404, { error: "not_found", path: req.url });
|
||||
});
|
||||
|
||||
server.listen(port, bind, () => {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`[aibot-bridge] listening http://${bind}:${port}/health wecomAibotEnabled=${enabled}`
|
||||
`[aibot-bridge] http://${bind}:${port}/health forward→${ingressUrl} enabled=${enabled}`
|
||||
);
|
||||
if (enabled) {
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -48,4 +144,9 @@ server.listen(port, bind, () => {
|
||||
"[aibot-bridge] WECOM_AIBOT_ENABLED=true:请确认已停现网桥且不会双连同一 Bot"
|
||||
);
|
||||
}
|
||||
// 传输壳:enabled 时跑心跳循环;未接 SDK 前不会产生入站帧
|
||||
startLongConnectionLoop({
|
||||
enabled,
|
||||
forwardToAgent,
|
||||
});
|
||||
});
|
||||
|
||||
+8
-13
@@ -1,20 +1,15 @@
|
||||
# 字段合同(contracts)
|
||||
|
||||
本目录存放与现网等价的字段 / 字典投影 JSON。智能体写字段校验、主账写 TMS 字典投影前,**必须**以本目录真文件为准。
|
||||
本目录存放字段 / 字典投影 JSON。智能体写字段校验、主账写 TMS 字典投影前,**必须**以本目录**正式**文件为准。
|
||||
|
||||
## 需要从现网拷贝的文件
|
||||
## 当前状态
|
||||
|
||||
| 文件名 | 用途 |
|
||||
| 文件名 | 状态 |
|
||||
|---|---|
|
||||
| `inquiry-required-fields-v1.json` | 询价必填与条件激活合同 |
|
||||
| `tms-dictionary-projection-v2.json` | TMS 字典 EXACT_ONLY 投影合同 |
|
||||
| `inquiry-required-fields-v1.json` | 正式(已从现网对照源拉入) |
|
||||
| `tms-dictionary-projection-v2.json` | 正式(已从现网对照源拉入) |
|
||||
| `*.PLACEHOLDER.json` | 历史占位,加载器优先正式文件 |
|
||||
|
||||
骨架阶段仅放占位文件(见同名 `*.PLACEHOLDER.json`)。拿到现网拷贝后:
|
||||
备份另见 `_ref/prod-pull/contracts/`(勿提交密钥目录)。
|
||||
|
||||
1. 覆盖为正式文件名(去掉 `.PLACEHOLDER`)。
|
||||
2. 再实现字段校验 / 字典投影;没读真 JSON 不准写校验逻辑。
|
||||
|
||||
## 禁止
|
||||
|
||||
- 不要把合同只记在提示词里而不更新本目录。
|
||||
- 不要在代码里硬编码另一套字段名。
|
||||
代码加载:`agent.schema.load_contract` / `require_formal_contract`。
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "占位:请用现网 inquiry-required-fields-v1.json 覆盖为同名正式文件后再写字段校验。骨架阶段禁止依赖本文件做业务判断。",
|
||||
"_comment": "占位:正式合同由产品/仓库内审交付到同名 inquiry-required-fields-v1.json。禁止从现网服务器或现网发布目录拷贝。骨架阶段禁止依赖本文件做业务判断。",
|
||||
"schemaVersion": "v1-placeholder",
|
||||
"fields": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"contract_id": "INQUIRY_REQUIRED_FIELDS_V1",
|
||||
"status": "APPROVED",
|
||||
"approved_by": "user",
|
||||
"approved_at": "2026-08-30T02:30:00+08:00",
|
||||
"requirement_id": "REQ-20260830-030",
|
||||
"last_amended_requirement_id": "REQ-20260831-009 revision 1",
|
||||
"stage_semantics": {
|
||||
"PRICE_REQUIRED": "首次查价必须满足的字段;只按已确认的业务线和二级类型激活。",
|
||||
"COLLAB_REQUIRED": "首次查价字段保持有效后,进入协同阶段追加的必填字段。"
|
||||
},
|
||||
"value_validity": {
|
||||
"scope": "ONLY_CURRENTLY_ACTIVATED_REQUIRED_FIELDS",
|
||||
"completion_rule": "TRIMMED_NON_EMPTY_VALUE_IS_PRESENT",
|
||||
"content_validation": "DISABLED_LOCAL_VALUE_FORMAT_AND_COMPATIBILITY_VALIDATION_EXCEPT_DECLARED_MACHINE_TYPES",
|
||||
"accepted_user_values": "当前作用域字段收到任意非空值即视为已采集;本地不得按单位、车型、布尔文案、别名、格式或封闭枚举拒绝。SEA箱型箱量保持一个原始业务字段,结构化containerItems只由DeepSeek B投影;投影不可用时由TMS查询边返回1003。",
|
||||
"inactive_field_display_not_a_fact": "未激活字段摘要中的-只表示不适用,不作为字段值进入完整性校验。"
|
||||
},
|
||||
"model_scopes": {
|
||||
"PRICING": "DeepSeek B只接收本合同pricing_required中当前激活字段、必要路由身份和optional_projection_fields中当前业务线允许的可选事实;缺项只按pricing_required计算。",
|
||||
"COLLABORATION": "DeepSeek B接收同一票不可变销售原话、已确认运输模式和陆运二级类型,只返回当前激活协同字段;结果保存到独立快照。点击协同时必须重新执行本范围。"
|
||||
},
|
||||
"pricing_required": {
|
||||
"SEA": {
|
||||
"always": ["起运港", "目的港", "品名", "货量", "整柜或拼柜", "箱型箱量"],
|
||||
"display_names": {"货量": "货物数量"}
|
||||
},
|
||||
"AIR": {
|
||||
"always": ["起运港", "目的港", "品名", "件数", "毛重", "体积", "包装方式"],
|
||||
"display_names": {"起运港": "起运地", "目的港": "目的地", "毛重": "重量(KG)", "体积": "体积(CBM)", "包装方式": "包装类型"}
|
||||
},
|
||||
"LAND": {
|
||||
"国内运输拼车": {
|
||||
"always": ["起运港", "目的港", "品名", "体积", "毛重"],
|
||||
"display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "体积": "体积(CBM)", "毛重": "重量(KG)"}
|
||||
},
|
||||
"国内运输整车": {
|
||||
"always": ["起运港", "目的港", "品名", "车型数量"],
|
||||
"display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "车型数量": "车型/数量"}
|
||||
},
|
||||
"跨境集拼": {
|
||||
"always": ["起运港", "目的港", "品名", "体积", "毛重"],
|
||||
"display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "体积": "体积(CBM)", "毛重": "重量(KG)"}
|
||||
},
|
||||
"跨境整车": {
|
||||
"always": ["起运港", "目的港", "品名"],
|
||||
"display_names": {"起运港": "起运地", "目的港": "目的地", "品名": "货物品名"}
|
||||
},
|
||||
"中港": {
|
||||
"always": ["起运港", "目的港", "品名", "车型数量"],
|
||||
"display_names": {"起运港": "始发站", "目的港": "目的地", "品名": "货物品名", "车型数量": "车型/数量"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"optional_projection_fields": {
|
||||
"LAND": ["件数", "毛重", "体积", "时效要求", "cargo_items"],
|
||||
"semantics": "这些字段不是首次查价必填项;只有销售原话明确出现且DeepSeek B给出逐项证据时才保真保存。代码不得从原始自然语言猜测,只可对模型结构化cargo_items做单位与算术投影。"
|
||||
},
|
||||
"collaboration_required": {
|
||||
"SEA": ["贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁"],
|
||||
"AIR": ["是否含电", "是否含磁"],
|
||||
"LAND": {
|
||||
"国内运输整车": ["时效要求", "客户名称", "货值", "包装方式", "是否危险品"],
|
||||
"国内运输拼车": ["时效要求", "客户名称", "包装方式", "货值", "是否危险品"],
|
||||
"跨境整车": ["时效要求", "客户名称", "HS编码", "车型数量", "贸易条款", "货值", "包装方式", "是否危险品"],
|
||||
"跨境集拼": ["时效要求", "客户名称", "HS编码", "贸易条款", "包装方式", "是否危险品"],
|
||||
"中港": {"same_as": "跨境整车"}
|
||||
}
|
||||
},
|
||||
"collaboration_value_contracts": {
|
||||
"enforcement": "SUGGESTION_ONLY_USER_TYPED_NON_EMPTY_VALUE_IS_ACCEPTED",
|
||||
"LAND": {
|
||||
"时效要求": {
|
||||
"国内运输整车": ["普件(常用线路/常规车型)", "普件(非常用线路/临时线路/特种车型)", "急件"],
|
||||
"国内运输拼车": ["普件(常用线路/常规车型)", "普件(非常用线路/临时线路/特种车型)", "急件"],
|
||||
"跨境整车": ["普件(中港、东南亚常用线路/常规车型)", "普件(中亚、中欧、东南亚涉及国内+国外段)", "普件(中亚、中欧、东南亚涉及境外段清派等特殊要求)", "急件"],
|
||||
"跨境集拼": ["普件(中港、东南亚常用线路/常规车型)", "普件(中亚、中欧、东南亚涉及国内+国外段)", "普件(中亚、中欧、东南亚涉及境外段清派等特殊要求)", "急件"],
|
||||
"中港": {"same_as": "跨境整车"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"legacy_read_aliases": {
|
||||
"时效要求": ["服务档位", "货物类型", "服务类型", "普件/急件"]
|
||||
},
|
||||
"semantic_ownership": {
|
||||
"owner": "DeepSeek B",
|
||||
"must_output": ["raw_value", "normalized_value", "evidence", "segment_binding", "cargo_binding"],
|
||||
"forbidden_local_capabilities": ["semantic_regex", "alias_inference", "content_normalization", "semantic_fallback", "value_format_rejection", "closed_enum_value_rejection"]
|
||||
},
|
||||
"machine_types": {},
|
||||
"deterministic_invariants": [
|
||||
"SEA货物数量与AIR件数是不同字段;自然语言数量落入哪个字段只能由DeepSeek B依据已解析业务线决定。",
|
||||
"SEA箱型箱量是一个首次查价必填字段,整柜/FCL和拼柜/LCL均激活;任意非空原值完成采集。",
|
||||
"SEA箱型箱量原值到containerItems.type/quantity的结构化投影只由DeepSeek B输出,本地不得拆分、去单位或限制格式。",
|
||||
"DeepSeek B不能形成可用containerItems时不得重新补问箱型或箱量,由item投影或TMS查询边返回业务1003。",
|
||||
"AIR包装类型使用内部字段包装方式承载,但只在AIR首次查价作用域激活。",
|
||||
"尺寸原文不得在本地代码中用正则、别名或关键词解析为体积(CBM);DeepSeek B已输出带证据的结构化cargo_items后,代码可按单位做确定性乘法和加总并投影总体积。",
|
||||
"协同字段只能在对应基础查价字段满足后追加,不得反向扩大首次查价必填。",
|
||||
"代码只校验字段键作用域、JSON结构、非空、条件激活、权限、版本与幂等;字段内容格式、规范性和是否适合报价由模型与下游报价服务负责。"
|
||||
]
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"_comment": "占位:请用现网 tms-dictionary-projection-v2.json 覆盖为同名正式文件后再写字典投影。骨架阶段禁止依赖本文件做业务判断。",
|
||||
"_comment": "占位:正式合同由产品/仓库内审交付到同名 tms-dictionary-projection-v2.json。禁止从现网服务器或现网发布目录拷贝。骨架阶段禁止依赖本文件做业务判断。",
|
||||
"schemaVersion": "v2-placeholder",
|
||||
"projections": []
|
||||
}
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
{
|
||||
"schema_version": 2,
|
||||
"contract_version": "TMS-DICTIONARY-PROJECTION-V2",
|
||||
"supersedes": ".project-control/architecture/tms-dictionary-projection-v1.json",
|
||||
"required_fields_contract_ref": ".project-control/architecture/inquiry-required-fields-v1.json",
|
||||
"business_semantics_owner": "DEEPSEEK_B",
|
||||
"protocol_projection_owner": "JAVA_TMS_ADAPTER",
|
||||
"last_amended_requirement_id": "REQ-20260831-009 revision 1",
|
||||
"matching_policy": {
|
||||
"mode": "EXACT_ONLY",
|
||||
"allowed_keys": ["source_code", "source_name"],
|
||||
"allow_regex": false,
|
||||
"allow_fuzzy": false,
|
||||
"allow_alias_inference": false,
|
||||
"allow_suffix_trimming": false,
|
||||
"allow_last_write_wins": false,
|
||||
"multiple_distinct_values": "AMBIGUOUS",
|
||||
"zero_values": "UNMAPPED",
|
||||
"air_package_fallback": {
|
||||
"target_field": "packageType",
|
||||
"value": 1,
|
||||
"meaning": "全部",
|
||||
"preserve_raw_source": true
|
||||
}
|
||||
},
|
||||
"catalog_statuses": [
|
||||
"PROJECTABLE",
|
||||
"AMBIGUOUS_NAME_GROUP",
|
||||
"SOURCE_VALUE_MISSING",
|
||||
"REFERENCE_ONLY"
|
||||
],
|
||||
"resolution_statuses": [
|
||||
"RESOLVED",
|
||||
"UNMAPPED",
|
||||
"AMBIGUOUS",
|
||||
"SOURCE_VALUE_MISSING",
|
||||
"CARDINALITY_UNSUPPORTED",
|
||||
"NOT_APPLICABLE"
|
||||
],
|
||||
"dictionary_domains": [
|
||||
"air_airport",
|
||||
"air_packaging_type",
|
||||
"land_address",
|
||||
"land_vehicle",
|
||||
"sea_container",
|
||||
"land_transport_type",
|
||||
"currency_reference"
|
||||
],
|
||||
"projection_rules": [
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"transport_type": null,
|
||||
"source_field": "origin",
|
||||
"target_field": "originCode",
|
||||
"dictionary_domain": "air_airport",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"transport_type": null,
|
||||
"source_field": "destination",
|
||||
"target_field": "destinationCode",
|
||||
"dictionary_domain": "air_airport",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "AIR",
|
||||
"transport_type": null,
|
||||
"source_field": "packagingType",
|
||||
"target_field": "packageType",
|
||||
"dictionary_domain": "air_packaging_type",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": "全部"
|
||||
},
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"transport_type": null,
|
||||
"source_field": "origin",
|
||||
"target_field": "originCode",
|
||||
"dictionary_domain": "land_address",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"transport_type": null,
|
||||
"source_field": "destination",
|
||||
"target_field": "destinationCode",
|
||||
"dictionary_domain": "land_address",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"transport_type": "DOMESTIC_FTL|HK",
|
||||
"source_field": "vehicleItems",
|
||||
"target_field": "vehicleType",
|
||||
"dictionary_domain": "land_vehicle",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "SEA",
|
||||
"transport_type": null,
|
||||
"source_field": "containerItems",
|
||||
"target_field": "containerType",
|
||||
"dictionary_domain": "sea_container",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
},
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"transport_type": null,
|
||||
"source_field": "transportType",
|
||||
"target_field": "landTransportType",
|
||||
"dictionary_domain": "land_transport_type",
|
||||
"output_column": "projection_value",
|
||||
"default_source_value": null
|
||||
}
|
||||
],
|
||||
"direct_projection_rules": [
|
||||
{
|
||||
"transport_mode": "SEA",
|
||||
"transport_type": null,
|
||||
"source_field": "containerItems",
|
||||
"source_item_field": "quantity",
|
||||
"target_field": "containerQty",
|
||||
"value_type": "positive_integer",
|
||||
"dictionary_domain": null,
|
||||
"unit_in_value": false
|
||||
}
|
||||
],
|
||||
"non_blocking_projection_failures": [
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"dictionary_domain": "land_address",
|
||||
"target_fields": ["originCode", "destinationCode"],
|
||||
"resolution_status": "UNMAPPED",
|
||||
"required_source_names": ["origin", "destination"],
|
||||
"behavior": "保留投影审计;名称非空时以名称且代码空值继续真实TMS查询。AMBIGUOUS、SOURCE_VALUE_MISSING及其他业务线仍失败关闭。"
|
||||
}
|
||||
],
|
||||
"direct_passthrough_fields": [
|
||||
{
|
||||
"transport_mode": "LAND",
|
||||
"source_field": "时效要求",
|
||||
"target_field": "routeCategory",
|
||||
"behavior": "逐字透传用户值,不做字典或本地语义映射。"
|
||||
}
|
||||
],
|
||||
"unsupported_projection_fields": [
|
||||
{
|
||||
"field": "seaPortCode",
|
||||
"reason": "SOURCE_WORKBOOK_HAS_NO_SEA_PORT_DICTIONARY"
|
||||
},
|
||||
{
|
||||
"field": "currency",
|
||||
"reason": "WORKBOOK_NUMERIC_VALUE_IS_REFERENCE_ONLY_NOT_ISO4217_PROJECTION"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
# 本地一键:同步源码 → 构建最新 dist → 上传 → 切后台到 8180
|
||||
# 用法:在仓库根目录 .\deploy\Deploy-YtdAdminLatest.ps1
|
||||
# 要求:SSH 密钥 ~/.ssh/id_ed25519_ytd;服务器 8180/8910/8080 已在听。
|
||||
# 副作用:更新 E:\wwwroot\admin\current;改 Nginx /jeecgboot -> 8180;不碰 C:\yutongda。
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$RepoRoot = Split-Path -Parent $PSScriptRoot
|
||||
$SrcBackend = Join-Path $RepoRoot 'inquiry-backend'
|
||||
$BuildRoot = 'C:\ytd\inquiry-backend'
|
||||
$Key = Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd'
|
||||
$Remote = 'ytd_extdev_0903@129.211.181.80'
|
||||
$Incoming = 'C:/Users/ytd_extdev_0903/incoming'
|
||||
|
||||
if (-not (Test-Path $Key)) { throw "missing SSH key $Key" }
|
||||
if (-not (Test-Path $SrcBackend)) { throw "missing $SrcBackend" }
|
||||
|
||||
Write-Host '=== 1) sync source to english build path ==='
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path $BuildRoot) | Out-Null
|
||||
# 排除 node_modules / dist / .git,再单独保证依赖
|
||||
robocopy $SrcBackend $BuildRoot /MIR /XD node_modules dist .git .vite .husky /XF *.log /NFL /NDL /NJH /NJS /nc /ns /np | Out-Null
|
||||
# robocopy exit 0-7 ok
|
||||
if ($LASTEXITCODE -ge 8) { throw "robocopy failed code=$LASTEXITCODE" }
|
||||
Copy-Item (Join-Path $SrcBackend '.env.production') (Join-Path $BuildRoot '.env.production') -Force
|
||||
|
||||
if (-not (Test-Path (Join-Path $BuildRoot 'node_modules'))) {
|
||||
Write-Host '=== pnpm install ==='
|
||||
Push-Location $BuildRoot
|
||||
try { pnpm install --frozen-lockfile } catch { pnpm install }
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
Write-Host '=== 2) build production dist ==='
|
||||
Push-Location $BuildRoot
|
||||
if (Test-Path 'dist') { Remove-Item 'dist' -Recurse -Force }
|
||||
pnpm run build
|
||||
if ($LASTEXITCODE -ne 0) { Pop-Location; throw "build failed $LASTEXITCODE" }
|
||||
if (-not (Test-Path 'dist\index.html')) { Pop-Location; throw 'dist/index.html missing' }
|
||||
Pop-Location
|
||||
|
||||
$zip = Join-Path $env:TEMP 'admin-dist.zip'
|
||||
if (Test-Path $zip) { Remove-Item $zip -Force }
|
||||
Compress-Archive -Path (Join-Path $BuildRoot 'dist\*') -DestinationPath $zip -Force
|
||||
Write-Host ("zip bytes=" + (Get-Item $zip).Length)
|
||||
|
||||
Write-Host '=== 3) upload ==='
|
||||
scp -o BatchMode=yes -i $Key `
|
||||
$zip `
|
||||
(Join-Path $RepoRoot 'deploy\ai.conf.cutover-8180') `
|
||||
(Join-Path $RepoRoot 'deploy\_remote_cutover_admin_8180.ps1') `
|
||||
"${Remote}:${Incoming}/"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp failed $LASTEXITCODE" }
|
||||
|
||||
Write-Host '=== 4) remote cutover ==='
|
||||
ssh -o BatchMode=yes -i $Key $Remote "powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_remote_cutover_admin_8180.ps1"
|
||||
if ($LASTEXITCODE -ne 0) { throw "remote cutover failed $LASTEXITCODE" }
|
||||
|
||||
Write-Host 'DONE: admin latest published; /jeecgboot -> 8180'
|
||||
Write-Host '请强刷 https://ai.ytd-scm.com/ (Ctrl+F5),用 Jeecg 账号登录'
|
||||
+17
-7
@@ -1,10 +1,20 @@
|
||||
# 部署占位(仅测试机)
|
||||
# 部署占位(仅测试机 / 切流后 wwwroot)
|
||||
|
||||
新系统与现网同机并存时:
|
||||
新系统运行根:`E:\wwwroot`(`admin\current` / `agent\current`)。
|
||||
|
||||
- 运行根只允许 `C:\ytd-test\`(或本机开发目录)。
|
||||
- **禁止**改 `C:\yutongda\ops\active-release*.json`,禁止占现网 8080 / 8810 / 8813。
|
||||
- **禁止**改 Nginx `ai.conf` 回调 / H5 反代(切流前公网仍进现网)。
|
||||
- 并存期 **禁止**让测试 AIBOT 桥再连现网 Bot 长连接。
|
||||
## 每次发最新后台(正式 Jeecg Vue)
|
||||
|
||||
脚本见 `Start-YtdTestScaffold.ps1`:仅用于本机/测试机起骨架进程做健康检查,不是正式发布器。
|
||||
在仓库根执行:
|
||||
|
||||
```powershell
|
||||
.\deploy\Deploy-YtdAdminLatest.ps1
|
||||
```
|
||||
|
||||
会:同步 `inquiry-backend` → 英文路径构建 → 上传 `admin-dist.zip` → 切 `admin\current` → Nginx `/jeecgboot` 指到 **8180**。
|
||||
禁止改 `C:\yutongda`。现网旧路径 `/jeecg-boot` 仍可指 8080 作兼容。
|
||||
|
||||
## 其他
|
||||
|
||||
- 智能体安全发布:`_remote_safe_deploy_agent.ps1`(不改 Nginx)
|
||||
- 主账 WMI 拉起:`_remote_wmi_start_ledger.ps1`
|
||||
- 并存期勿双连 AIBOT(8913)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# 只读:拉服务器新系统 8910 智能体日志中的企微相关行与错误行。
|
||||
# 用法(仓库根或任意目录):
|
||||
# .\deploy\Watch-Agent8910.ps1 # 持续监听(Ctrl+C 结束)
|
||||
# .\deploy\Watch-Agent8910.ps1 -Once # 只看最近一段
|
||||
# .\deploy\Watch-Agent8910.ps1 -All # 不过滤关键字,整行刷出
|
||||
#
|
||||
# 说明:
|
||||
# - 盯的是 E:\wwwroot\logs\agent-http.*.log(新系统 8910),不碰现网 8810/8811。
|
||||
# - 切流前公网 /wecom/callback 仍进现网;本脚本主要看到 inject、出站、本机调 8910 的错误。
|
||||
# - 依赖本机 SSH 密钥 ~/.ssh/id_ed25519_ytd。
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Once,
|
||||
[switch]$All,
|
||||
[int]$Tail = 80
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Key = Join-Path $env:USERPROFILE '.ssh\id_ed25519_ytd'
|
||||
$Remote = 'ytd_extdev_0903@129.211.181.80'
|
||||
$LogOut = 'E:\wwwroot\logs\agent-http.out.log'
|
||||
$LogErr = 'E:\wwwroot\logs\agent-http.err.log'
|
||||
|
||||
# 企微回调 / 出站 / 注入 / 常见失败码;-All 时不使用
|
||||
$Pattern = 'wecom|callback|inject|outbox|inbox|errcode|errmsg|60020|ERROR|WARNING|Traceback|Exception|health|sender|touser|crypto'
|
||||
|
||||
if (-not (Test-Path $Key)) {
|
||||
throw "缺少 SSH 密钥: $Key"
|
||||
}
|
||||
|
||||
Write-Host "目标: ${Remote}"
|
||||
Write-Host "日志: $LogOut"
|
||||
Write-Host " $LogErr"
|
||||
if ($All) {
|
||||
Write-Host '过滤: 关闭(全部输出)'
|
||||
} else {
|
||||
Write-Host "过滤: $Pattern"
|
||||
}
|
||||
Write-Host '---'
|
||||
|
||||
# 远程脚本:先打 health,再 Tail;-Wait 仅在非 Once 时启用
|
||||
$waitLiteral = if ($Once) { '$false' } else { '$true' }
|
||||
$allLiteral = if ($All) { '$true' } else { '$false' }
|
||||
|
||||
$remote = @"
|
||||
`$ErrorActionPreference = 'Continue'
|
||||
`$out = '$LogOut'
|
||||
`$err = '$LogErr'
|
||||
`$tail = $Tail
|
||||
`$wait = $waitLiteral
|
||||
`$all = $allLiteral
|
||||
`$pat = '$Pattern'
|
||||
|
||||
Write-Output '=== remote health 8910 ==='
|
||||
try {
|
||||
`$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5
|
||||
Write-Output `$h.Content
|
||||
} catch {
|
||||
Write-Output ('health_fail: ' + `$_.Exception.Message)
|
||||
}
|
||||
Write-Output '=== log tail ==='
|
||||
|
||||
`$files = @()
|
||||
if (Test-Path `$out) { `$files += `$out } else { Write-Output ('MISSING ' + `$out) }
|
||||
if (Test-Path `$err) { `$files += `$err } else { Write-Output ('MISSING ' + `$err) }
|
||||
if (`$files.Count -eq 0) { throw 'no agent-http log files' }
|
||||
|
||||
function Show-Line([string]`$line) {
|
||||
if (`$all) {
|
||||
Write-Output `$line
|
||||
return
|
||||
}
|
||||
if (`$line -match `$pat) {
|
||||
Write-Output `$line
|
||||
}
|
||||
}
|
||||
|
||||
if (-not `$wait) {
|
||||
foreach (`$f in `$files) {
|
||||
Write-Output ('--- ' + `$f + ' ---')
|
||||
Get-Content -Path `$f -Tail `$tail -ErrorAction SilentlyContinue | ForEach-Object { Show-Line `$_ }
|
||||
}
|
||||
exit 0
|
||||
}
|
||||
|
||||
Write-Output 'following... (Ctrl+C to stop)'
|
||||
Get-Content -Path `$files -Tail `$tail -Wait -ErrorAction SilentlyContinue | ForEach-Object { Show-Line `$_ }
|
||||
"@
|
||||
|
||||
$remoteFile = Join-Path $env:TEMP 'ytd-watch-agent8910-remote.ps1'
|
||||
Set-Content -Path $remoteFile -Value $remote -Encoding UTF8
|
||||
|
||||
$incoming = 'C:/Users/ytd_extdev_0903/incoming/_watch_agent8910.ps1'
|
||||
& scp -o BatchMode=yes -o ConnectTimeout=12 -i $Key $remoteFile "${Remote}:${incoming}"
|
||||
if ($LASTEXITCODE -ne 0) { throw "scp failed: $LASTEXITCODE" }
|
||||
|
||||
& ssh -o BatchMode=yes -o ConnectTimeout=12 -i $Key $Remote `
|
||||
"powershell -NoProfile -ExecutionPolicy Bypass -File C:\Users\ytd_extdev_0903\incoming\_watch_agent8910.ps1"
|
||||
if ($LASTEXITCODE -ne 0 -and -not $Once) {
|
||||
# Ctrl+C 常非 0,不当作部署失败
|
||||
exit $LASTEXITCODE
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
# 发布后验收:端口、健康、现网未误停
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== ports ==='
|
||||
foreach ($x in 8080, 8810, 8813, 8180, 8910, 8913) {
|
||||
$c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { Write-Output "$x LISTEN pid=$($c.OwningProcess)" } else { Write-Output "$x down" }
|
||||
}
|
||||
|
||||
Write-Output '=== health ==='
|
||||
foreach ($u in @(
|
||||
'http://127.0.0.1:8910/health',
|
||||
'http://127.0.0.1:8180/jeecgboot/',
|
||||
'http://127.0.0.1:8180/jeecgboot/sys/randomImage/deploycheck',
|
||||
'http://127.0.0.1:8080/jeecg-boot/'
|
||||
)) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 8
|
||||
Write-Output ("OK " + $r.StatusCode + " " + $u)
|
||||
} catch {
|
||||
$code = $null
|
||||
try { $code = $_.Exception.Response.StatusCode.value__ } catch {}
|
||||
Write-Output ("FAIL " + $u + " code=" + $code)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output '=== worker ==='
|
||||
$found = $false
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
if ($_.CommandLine -and $_.CommandLine -like '*worker_app*' -and $_.CommandLine -like '*wwwroot*agent*') {
|
||||
Write-Output ("worker pid=" + $_.ProcessId)
|
||||
$found = $true
|
||||
}
|
||||
}
|
||||
if (-not $found) { Write-Output 'worker NOT found' }
|
||||
|
||||
Write-Output '=== pointer ==='
|
||||
Get-Content 'E:\wwwroot\ops\active-release.json' -Raw
|
||||
|
||||
Write-Output '=== yutongda pointer untouched ==='
|
||||
if (Test-Path 'C:\yutongda\ops\active-release.json') {
|
||||
Write-Output 'C:\yutongda\ops\active-release.json still exists (not deleted)'
|
||||
}
|
||||
|
||||
Write-Output 'ACCEPT_DONE'
|
||||
@@ -0,0 +1,13 @@
|
||||
# 检查 config overlay 是否含 redis username,以及最近失败原因
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$cfg = 'E:\wwwroot\inquiry-new\config\application-inquiry.yml'
|
||||
Write-Output '=== config exists ==='
|
||||
Test-Path $cfg
|
||||
if (Test-Path $cfg) {
|
||||
Get-Content $cfg | Select-Object -First 25
|
||||
}
|
||||
Write-Output '=== log markers ==='
|
||||
Select-String -Path 'E:\wwwroot\inquiry-new\logs\stdout.log' -Pattern 'WRONGPASS|redisContainer|Started Jeecg|Application run failed' |
|
||||
Select-Object -Last 12 |
|
||||
ForEach-Object { $_.Line.Substring(0, [Math]::Min(220, $_.Line.Length)) }
|
||||
Write-Output 'CFG_CHECK_DONE'
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$hits = Select-String -Path 'E:\wwwroot\admin\current\assets\*.js' -Pattern '数据进入主账' -SimpleMatch -ErrorAction SilentlyContinue
|
||||
if ($hits) {
|
||||
Write-Output 'HINT_STILL_PRESENT'
|
||||
$hits | Select-Object -First 3 | ForEach-Object { $_.Filename }
|
||||
} else {
|
||||
Write-Output 'HINT_GONE'
|
||||
}
|
||||
Write-Output 'CHECK_DONE'
|
||||
@@ -0,0 +1,77 @@
|
||||
# After redeploy: remove all releases except current junction targets
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$Root = 'E:\wwwroot'
|
||||
|
||||
function Resolve-Link([string]$p) {
|
||||
if (-not (Test-Path $p)) { return $null }
|
||||
$i = Get-Item $p -Force
|
||||
if ($i.Target -and $i.Target.Count -gt 0) { return [string]$i.Target[0] }
|
||||
return $null
|
||||
}
|
||||
|
||||
$keepAgent = Resolve-Link (Join-Path $Root 'agent\current')
|
||||
$keepAdmin = Resolve-Link (Join-Path $Root 'admin\current')
|
||||
Write-Output ('keepAgent=' + $keepAgent)
|
||||
Write-Output ('keepAdmin=' + $keepAdmin)
|
||||
|
||||
function Try-Remove([string]$path) {
|
||||
try {
|
||||
Remove-Item $path -Recurse -Force -ErrorAction Stop
|
||||
Write-Output ('REMOVED ' + $path)
|
||||
return $true
|
||||
} catch {
|
||||
Write-Output ('LOCK ' + $path + ' :: ' + $_.Exception.Message)
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# Stop python/java only if commandline clearly points at OLD release paths we will delete
|
||||
$keepAgentRoot = $null
|
||||
if ($keepAgent) {
|
||||
# .../releases/<id>/agent -> releases/<id>
|
||||
$keepAgentRoot = Split-Path $keepAgent -Parent
|
||||
}
|
||||
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$cl = $_.CommandLine
|
||||
if (-not $cl) { return }
|
||||
if ($cl -like '*\agent\releases\*' -and $keepAgentRoot -and $cl -notlike ('*' + $keepAgentRoot + '*')) {
|
||||
if ($cl -like '*python*' -or $cl -like '*http_app*' -or $cl -like '*worker_app*') {
|
||||
Write-Output ('stop stale pid=' + $_.ProcessId)
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
if (Test-Path (Join-Path $Root 'agent\releases')) {
|
||||
Get-ChildItem (Join-Path $Root 'agent\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$rel = $_.FullName
|
||||
$agentHomePath = Join-Path $rel 'agent'
|
||||
if ($keepAgent -and ($keepAgent -eq $agentHomePath -or $keepAgent.StartsWith($rel + '\'))) {
|
||||
Write-Output ('KEEP ' + $rel)
|
||||
} else {
|
||||
Try-Remove $rel | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (Test-Path (Join-Path $Root 'admin\releases')) {
|
||||
Get-ChildItem (Join-Path $Root 'admin\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$rel = $_.FullName
|
||||
$dist = Join-Path $rel 'dist'
|
||||
if ($keepAdmin -and ($keepAdmin -eq $dist -or $keepAdmin.StartsWith($rel + '\'))) {
|
||||
Write-Output ('KEEP ' + $rel)
|
||||
} else {
|
||||
Try-Remove $rel | Out-Null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# trim env bak keep 2 newest
|
||||
Get-ChildItem $Root -Filter '.env.bak*' -ErrorAction SilentlyContinue |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -Skip 2 |
|
||||
ForEach-Object { Try-Remove $_.FullName | Out-Null }
|
||||
|
||||
Write-Output 'CLEANUP_AFTER_OK'
|
||||
@@ -0,0 +1,119 @@
|
||||
# Cleanup old releases under E:\wwwroot; keep current targets only
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = 'E:\wwwroot'
|
||||
|
||||
function Get-JunctionTarget([string]$link) {
|
||||
if (-not (Test-Path $link)) { return $null }
|
||||
$item = Get-Item $link -Force
|
||||
if ($item.Attributes -band [IO.FileAttributes]::ReparsePoint) {
|
||||
cmd /c ("dir /AL `"$link`"") | Out-Null
|
||||
# Resolve via Get-Item Target if available
|
||||
try {
|
||||
if ($item.Target -and $item.Target.Count -gt 0) { return [string]$item.Target[0] }
|
||||
} catch {}
|
||||
}
|
||||
# Fallback: read active-release.json
|
||||
return $null
|
||||
}
|
||||
|
||||
$ptrPath = Join-Path $Root 'ops\active-release.json'
|
||||
$keepAgent = $null
|
||||
$keepAdmin = $null
|
||||
if (Test-Path $ptrPath) {
|
||||
$ptr = Get-Content $ptrPath -Raw | ConvertFrom-Json
|
||||
if ($ptr.agentHome) { $keepAgent = [string]$ptr.agentHome }
|
||||
if ($ptr.adminRelease) { $keepAdmin = [string]$ptr.adminRelease }
|
||||
}
|
||||
|
||||
$agentCurrent = Join-Path $Root 'agent\current'
|
||||
$adminCurrent = Join-Path $Root 'admin\current'
|
||||
$t1 = Get-JunctionTarget $agentCurrent
|
||||
$t2 = Get-JunctionTarget $adminCurrent
|
||||
if ($t1) { $keepAgent = $t1 }
|
||||
if ($t2) { $keepAdmin = $t2 }
|
||||
|
||||
# Resolve junction via fsutil / dir listing
|
||||
function Resolve-Link([string]$p) {
|
||||
if (-not (Test-Path $p)) { return $null }
|
||||
$out = cmd /c ("fsutil reparsepoint query `"$p`"") 2>$null
|
||||
# Alternative: (Get-Item).Target
|
||||
$i = Get-Item $p -Force
|
||||
if ($i.LinkType -eq 'Junction' -or ($i.Attributes -band [IO.FileAttributes]::ReparsePoint)) {
|
||||
if ($i.Target) { return ([string]$i.Target[0]) }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
$ka = Resolve-Link $agentCurrent
|
||||
$kb = Resolve-Link $adminCurrent
|
||||
if ($ka) { $keepAgent = $ka }
|
||||
if ($kb) { $keepAdmin = $kb }
|
||||
|
||||
Write-Output ('keepAgent=' + $keepAgent)
|
||||
Write-Output ('keepAdmin=' + $keepAdmin)
|
||||
|
||||
$removed = @()
|
||||
# agent releases: E:\wwwroot\agent\releases\<id>\agent or <id>
|
||||
$agentRelRoot = Join-Path $Root 'agent\releases'
|
||||
if (Test-Path $agentRelRoot) {
|
||||
Get-ChildItem $agentRelRoot -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$rel = $_.FullName
|
||||
$agentHome = Join-Path $rel 'agent'
|
||||
$isKeep = $false
|
||||
if ($keepAgent) {
|
||||
if ($keepAgent -eq $agentHome -or $keepAgent -eq $rel -or $keepAgent.StartsWith($rel + '\')) { $isKeep = $true }
|
||||
}
|
||||
if (-not $isKeep) {
|
||||
Write-Output ('REMOVE agent ' + $rel)
|
||||
Remove-Item $rel -Recurse -Force
|
||||
$removed += $rel
|
||||
} else {
|
||||
Write-Output ('KEEP agent ' + $rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$adminRelRoot = Join-Path $Root 'admin\releases'
|
||||
if (Test-Path $adminRelRoot) {
|
||||
Get-ChildItem $adminRelRoot -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$rel = $_.FullName
|
||||
$dist = Join-Path $rel 'dist'
|
||||
$isKeep = $false
|
||||
if ($keepAdmin) {
|
||||
if ($keepAdmin -eq $dist -or $keepAdmin -eq $rel -or $keepAdmin.StartsWith($rel + '\')) { $isKeep = $true }
|
||||
}
|
||||
if (-not $isKeep) {
|
||||
Write-Output ('REMOVE admin ' + $rel)
|
||||
Remove-Item $rel -Recurse -Force
|
||||
$removed += $rel
|
||||
} else {
|
||||
Write-Output ('KEEP admin ' + $rel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# old env backups older than keep recent 2
|
||||
Get-ChildItem $Root -Filter '.env.bak*' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -Skip 2 | ForEach-Object {
|
||||
Write-Output ('REMOVE bak ' + $_.Name)
|
||||
Remove-Item $_.FullName -Force
|
||||
$removed += $_.FullName
|
||||
}
|
||||
|
||||
# incoming zips older
|
||||
$incoming = 'C:\Users\ytd_extdev_0903\incoming'
|
||||
if (Test-Path $incoming) {
|
||||
Get-ChildItem $incoming -Include '*.zip','jeecg-system-start*.jar' -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.LastWriteTime -lt (Get-Date).AddDays(-1) } |
|
||||
ForEach-Object {
|
||||
Write-Output ('REMOVE incoming ' + $_.Name)
|
||||
Remove-Item $_.FullName -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output ('removed_count=' + $removed.Count)
|
||||
Write-Output 'CLEANUP_OK'
|
||||
|
||||
# ports sanity: must not touch yutongda
|
||||
foreach ($x in 8080,8180,8910) {
|
||||
$c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { Write-Output ("port $x LISTEN") } else { Write-Output ("port $x down") }
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
# Remote: publish latest admin dist + switch Nginx /jeecgboot -> 8180
|
||||
# Do not touch C:\yutongda. On nginx -t failure, restore backup.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$IncomingZip = 'C:\Users\ytd_extdev_0903\incoming\admin-dist.zip'
|
||||
$IncomingConf = 'C:\Users\ytd_extdev_0903\incoming\ai.conf.cutover-8180'
|
||||
$Root = 'E:\wwwroot'
|
||||
$Nginx = 'C:\nginx-1.18.0'
|
||||
$NginxConf = Join-Path $Nginx 'myconf\ai.conf'
|
||||
$Stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
|
||||
$RelId = 'admin-' + $Stamp
|
||||
$RelDist = Join-Path $Root ('admin\releases\' + $RelId + '\dist')
|
||||
$Current = Join-Path $Root 'admin\current'
|
||||
$PtrPath = Join-Path $Root 'ops\active-release.json'
|
||||
|
||||
if (-not (Test-Path $IncomingZip)) { throw 'missing admin-dist.zip' }
|
||||
if (-not (Test-Path $IncomingConf)) { throw 'missing ai.conf.cutover-8180' }
|
||||
|
||||
$ledger = Get-NetTCPConnection -LocalPort 8180 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $ledger) { throw 'CRITICAL: 8180 not listening' }
|
||||
$agent = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $agent) { throw 'CRITICAL: 8910 not listening' }
|
||||
$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $prod) { throw 'CRITICAL: prod 8080 down' }
|
||||
|
||||
Write-Output '=== backup nginx ==='
|
||||
$bak = $NginxConf + '.bak-before-admin-8180-' + $Stamp
|
||||
Copy-Item $NginxConf $bak -Force
|
||||
Write-Output ('bak=' + $bak)
|
||||
|
||||
Write-Output '=== install release dist ==='
|
||||
New-Item -ItemType Directory -Force -Path $RelDist | Out-Null
|
||||
Expand-Archive -Path $IncomingZip -DestinationPath $RelDist -Force
|
||||
if (-not (Test-Path (Join-Path $RelDist 'index.html'))) {
|
||||
$inner = Get-ChildItem $RelDist -Directory | Select-Object -First 1
|
||||
if ($inner -and (Test-Path (Join-Path $inner.FullName 'index.html'))) {
|
||||
$RelDist = $inner.FullName
|
||||
}
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $RelDist 'index.html'))) { throw 'index.html missing' }
|
||||
|
||||
Write-Output '=== switch admin current junction ==='
|
||||
if (Test-Path $Current) {
|
||||
cmd /c ('rmdir "' + $Current + '"') | Out-Null
|
||||
if (Test-Path $Current) { Remove-Item $Current -Recurse -Force }
|
||||
}
|
||||
cmd /c ('mklink /J "' + $Current + '" "' + $RelDist + '"')
|
||||
if (-not (Test-Path (Join-Path $Current 'index.html'))) { throw 'current index missing' }
|
||||
Write-Output ('CURRENT_OK=' + $Current + ' -> ' + $RelDist)
|
||||
|
||||
Write-Output '=== nginx conf no BOM ==='
|
||||
$bytes = [System.IO.File]::ReadAllBytes($IncomingConf)
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) {
|
||||
$list = New-Object System.Collections.Generic.List[byte]
|
||||
for ($i = 3; $i -lt $bytes.Length; $i++) { [void]$list.Add($bytes[$i]) }
|
||||
$bytes = $list.ToArray()
|
||||
}
|
||||
[System.IO.File]::WriteAllBytes($NginxConf, $bytes)
|
||||
|
||||
Set-Location $Nginx
|
||||
& .\nginx.exe -t
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Copy-Item $bak $NginxConf -Force
|
||||
throw 'nginx -t failed, restored'
|
||||
}
|
||||
Get-Process nginx -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 1
|
||||
try {
|
||||
Start-Service Nginx1.18 -ErrorAction Stop
|
||||
Write-Output 'SERVICE_STARTED'
|
||||
} catch {
|
||||
Start-Process -FilePath (Join-Path $Nginx 'nginx.exe') -WorkingDirectory $Nginx -WindowStyle Hidden
|
||||
Write-Output 'EXE_STARTED'
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
New-Item -ItemType Directory -Force -Path (Join-Path $Root 'ops') | Out-Null
|
||||
$prevAgentCurrent = Join-Path $Root 'agent\current'
|
||||
$prevAgentHome = $null
|
||||
if (Test-Path $PtrPath) {
|
||||
try {
|
||||
$old = Get-Content $PtrPath -Raw | ConvertFrom-Json
|
||||
if ($old.agentCurrent) { $prevAgentCurrent = [string]$old.agentCurrent }
|
||||
if ($old.agentHome) { $prevAgentHome = [string]$old.agentHome }
|
||||
} catch {}
|
||||
}
|
||||
$ptr = @{
|
||||
root = $Root
|
||||
releaseId = $RelId
|
||||
adminRelease = $RelDist
|
||||
adminCurrent = $Current
|
||||
agentCurrent = $prevAgentCurrent
|
||||
agentHome = $prevAgentHome
|
||||
nginxBak = $bak
|
||||
jeecgbootProxy = 'http://127.0.0.1:8180/jeecgboot/'
|
||||
updatedAt = (Get-Date).ToString('o')
|
||||
note = 'admin cutover to Jeecg Vue + ledger 8180'
|
||||
}
|
||||
($ptr | ConvertTo-Json) | Out-File -FilePath $PtrPath -Encoding utf8
|
||||
|
||||
Write-Output '=== smoke ==='
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/cutover' -UseBasicParsing -TimeoutSec 8
|
||||
Write-Output ('ledger=' + $h.StatusCode)
|
||||
} catch {
|
||||
Write-Output ('ledger_fail=' + $_.Exception.Message)
|
||||
}
|
||||
Write-Output 'ADMIN_CUTOVER_OK'
|
||||
@@ -0,0 +1,52 @@
|
||||
# Diagnose E:\wwwroot\.env encoding without printing secret values
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$path = 'E:\wwwroot\.env'
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
Write-Output ("bytes=" + $bytes.Length)
|
||||
Write-Output ("bom_utf8=" + ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191))
|
||||
Write-Output ("bom_utf16le=" + ($bytes.Length -ge 2 -and $bytes[0] -eq 255 -and $bytes[1] -eq 254))
|
||||
|
||||
function Show-Head([string]$label, [string]$text) {
|
||||
$lines = $text -split "`r?`n" | Select-Object -First 8
|
||||
Write-Output ("=== " + $label + " ===")
|
||||
foreach ($line in $lines) {
|
||||
if ($line -match '^(#|YTD_ENV=|PUBLIC_BASE_URL=|WECOM_CALLBACK_REPLY_TEXT=)') {
|
||||
if ($line -match '^WECOM_CALLBACK_REPLY_TEXT=') {
|
||||
$v = $line.Substring($line.IndexOf('=') + 1)
|
||||
Write-Output ("WECOM_CALLBACK_REPLY_TEXT len=" + $v.Length + " sample=" + $v.Substring(0, [Math]::Min(24, $v.Length)))
|
||||
} else {
|
||||
Write-Output $line
|
||||
}
|
||||
} elseif ($line -match '^[A-Z0-9_]+=') {
|
||||
$k = $line.Substring(0, $line.IndexOf('='))
|
||||
Write-Output ($k + "=***")
|
||||
} else {
|
||||
Write-Output $line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$utf8 = [System.Text.Encoding]::UTF8.GetString($bytes)
|
||||
Show-Head 'as_utf8' $utf8
|
||||
|
||||
$gbk = [System.Text.Encoding]::GetEncoding(936).GetString($bytes)
|
||||
Show-Head 'as_gbk' $gbk
|
||||
|
||||
# try: utf8 mis-decoded as gbk then saved as utf8 -> recover by encode gbk getbytes then utf8 getstring
|
||||
try {
|
||||
$rec1 = [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::GetEncoding(936).GetBytes($utf8))
|
||||
Show-Head 'recover_utf8_via_gbk_bytes' $rec1
|
||||
} catch {
|
||||
Write-Output 'recover1_fail'
|
||||
}
|
||||
|
||||
# try: latin1/cp1252 roundtrip
|
||||
try {
|
||||
$latin = [System.Text.Encoding]::GetEncoding(1252).GetString($bytes)
|
||||
$rec2 = [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::GetEncoding(1252).GetBytes($latin))
|
||||
Show-Head 'recover_cp1252_to_utf8' $rec2
|
||||
} catch {
|
||||
Write-Output 'recover2_fail'
|
||||
}
|
||||
|
||||
Write-Output 'ENV_DIAG_DONE'
|
||||
@@ -0,0 +1,18 @@
|
||||
# Find who locks old agent release dirs
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== processes mentioning releases ==='
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$cl = $_.CommandLine
|
||||
if ($cl -and ($cl -like '*agent\\releases*' -or $cl -like '*agent/releases*' -or $cl -like '*wwwroot*agent*')) {
|
||||
Write-Output ("pid=$($_.ProcessId) name=$($_.Name)")
|
||||
Write-Output (" " + $cl.Substring(0, [Math]::Min(300, $cl.Length)))
|
||||
}
|
||||
}
|
||||
Write-Output '=== netstat 8910 owner tree ==='
|
||||
$p = (Get-NetTCPConnection -LocalPort 8910 -State Listen -EA SilentlyContinue | Select-Object -First 1).OwningProcess
|
||||
if ($p) {
|
||||
$proc = Get-CimInstance Win32_Process -Filter "ProcessId=$p"
|
||||
Write-Output ("listen_pid=$p parent=$($proc.ParentProcessId)")
|
||||
Write-Output ($proc.CommandLine)
|
||||
}
|
||||
Write-Output 'LOCKERS_DONE'
|
||||
@@ -0,0 +1,85 @@
|
||||
# ASCII-only script: rewrite .env as UTF-8 BOM; Chinese via unicode escapes
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$path = 'E:\wwwroot\.env'
|
||||
$bak = 'E:\wwwroot\.env.bak-encoding-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
Copy-Item $path $bak -Force
|
||||
Write-Output ('backup=' + $bak)
|
||||
|
||||
function U([string]$escaped) {
|
||||
return [System.Text.RegularExpressions.Regex]::Unescape($escaped)
|
||||
}
|
||||
|
||||
$utf8BomEnc = New-Object System.Text.UTF8Encoding $true
|
||||
$raw = [System.IO.File]::ReadAllText($path, $utf8BomEnc)
|
||||
|
||||
$map = [ordered]@{}
|
||||
foreach ($line in ($raw -split "`r?`n")) {
|
||||
$t = $line.Trim()
|
||||
if (-not $t -or $t.StartsWith('#')) { continue }
|
||||
$i = $t.IndexOf('=')
|
||||
if ($i -lt 1) { continue }
|
||||
$k = $t.Substring(0, $i).Trim()
|
||||
$v = $t.Substring($i + 1)
|
||||
if (-not $map.Contains($k)) { $map[$k] = $v }
|
||||
}
|
||||
|
||||
# "询价智能体接通成功。已收到:{content}"
|
||||
$goodReply = U '\u8be2\u4ef7\u667a\u80fd\u4f53\u63a5\u901a\u6210\u529f\u3002\u5df2\u6536\u5230\uff1a{content}'
|
||||
$needFix = $true
|
||||
if ($map.Contains('WECOM_CALLBACK_REPLY_TEXT')) {
|
||||
$rt = [string]$map['WECOM_CALLBACK_REPLY_TEXT']
|
||||
if ($rt -match '\{content\}' -and $rt.Contains([char]0x8BE2) -and $rt.Contains([char]0x4EF7)) {
|
||||
$needFix = $false
|
||||
}
|
||||
}
|
||||
if ($needFix) {
|
||||
$map['WECOM_CALLBACK_REPLY_TEXT'] = $goodReply
|
||||
Write-Output 'fixed_reply_text'
|
||||
}
|
||||
|
||||
function V([string]$k, [string]$default = '') {
|
||||
if ($map.Contains($k)) { return [string]$map[$k] }
|
||||
return $default
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add((U '# \u53cb\u901a\u8fbe\u8be2\u4ef7\u667a\u80fd\u4f53 \u00b7 \u73af\u5883\u53d8\u91cf\uff08\u670d\u52a1\u5668 E:\\wwwroot\\.env\uff09'))
|
||||
$lines.Add((U '# \u7f16\u7801\uff1aUTF-8\uff08\u5e26 BOM\uff0c\u4fbf\u4e8e\u8bb0\u4e8b\u672c\u6b63\u786e\u6253\u5f00\uff09\u3002\u7981\u6b62\u63d0\u4ea4 Git\u3002'))
|
||||
$lines.Add((U '# \u5bf9\u7167\uff1aprompt/13-\u73af\u5883\u5bf9\u7167.md'))
|
||||
$lines.Add('')
|
||||
$lines.Add('YTD_ENV=' + (V 'YTD_ENV' 'test'))
|
||||
$lines.Add('')
|
||||
$lines.Add((U '# \u516c\u7f51\u4e0e\u4f01\u5fae\uff08\u57df\u540d\u4e0e\u6b63\u5f0f\u76f8\u540c\uff09'))
|
||||
$lines.Add('PUBLIC_BASE_URL=' + (V 'PUBLIC_BASE_URL'))
|
||||
$lines.Add('WECOM_CALLBACK_URL=' + (V 'WECOM_CALLBACK_URL'))
|
||||
$lines.Add('INQUIRY_FORM_PUBLIC_BASE_URL=' + (V 'INQUIRY_FORM_PUBLIC_BASE_URL'))
|
||||
$lines.Add('WECOM_CORP_ID=' + (V 'WECOM_CORP_ID'))
|
||||
$lines.Add('WECOM_AGENT_ID=' + (V 'WECOM_AGENT_ID'))
|
||||
$lines.Add('WECOM_SECRET=' + (V 'WECOM_SECRET'))
|
||||
$lines.Add('WECOM_CALLBACK_TOKEN=' + (V 'WECOM_CALLBACK_TOKEN'))
|
||||
$lines.Add('WECOM_ENCODING_AES_KEY=' + (V 'WECOM_ENCODING_AES_KEY'))
|
||||
$lines.Add('WECOM_CALLBACK_REPLY_TEXT=' + (V 'WECOM_CALLBACK_REPLY_TEXT' $goodReply))
|
||||
$lines.Add('WECOM_OPS_AGENT_ID=' + (V 'WECOM_OPS_AGENT_ID'))
|
||||
$lines.Add('DEV_INJECT_ENABLED=' + (V 'DEV_INJECT_ENABLED' 'false'))
|
||||
$lines.Add('')
|
||||
$lines.Add((U '# \u5176\u4f59\u914d\u7f6e\uff08\u7aef\u53e3 / \u5e93 / Redis / \u6a21\u578b\u7b49\uff09'))
|
||||
|
||||
$written = @(
|
||||
'YTD_ENV','PUBLIC_BASE_URL','WECOM_CALLBACK_URL','INQUIRY_FORM_PUBLIC_BASE_URL',
|
||||
'WECOM_CORP_ID','WECOM_AGENT_ID','WECOM_SECRET','WECOM_CALLBACK_TOKEN',
|
||||
'WECOM_ENCODING_AES_KEY','WECOM_CALLBACK_REPLY_TEXT','WECOM_OPS_AGENT_ID','DEV_INJECT_ENABLED'
|
||||
)
|
||||
foreach ($k in $map.Keys) {
|
||||
if ($written -contains $k) { continue }
|
||||
$lines.Add($k + '=' + $map[$k])
|
||||
}
|
||||
|
||||
$text = [string]::Join("`r`n", $lines.ToArray()) + "`r`n"
|
||||
[System.IO.File]::WriteAllText($path, $text, $utf8BomEnc)
|
||||
|
||||
$check = [System.IO.File]::ReadAllText($path, $utf8BomEnc)
|
||||
$first = ($check -split "`r?`n")[0]
|
||||
Write-Output ('first_line=' + $first)
|
||||
Write-Output ('keys=' + $map.Count)
|
||||
Write-Output ('reply_has_cn=' + ($check.Contains([char]0x8BE2) -and $check.Contains('{content}')))
|
||||
Write-Output 'ENV_REWRITE_OK'
|
||||
@@ -0,0 +1,17 @@
|
||||
# Fix active-release pointer after admin cutover (ASCII only)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
New-Item -ItemType Directory -Force -Path 'E:\wwwroot\ops' | Out-Null
|
||||
$obj = [ordered]@{
|
||||
root = 'E:\wwwroot'
|
||||
releaseId = 'admin-20260913-154002'
|
||||
adminCurrent = 'E:\wwwroot\admin\current'
|
||||
adminRelease = 'E:\wwwroot\admin\releases\admin-20260913-154002\dist'
|
||||
agentCurrent = 'E:\wwwroot\agent\current'
|
||||
agentHome = 'E:\wwwroot\agent\releases\agent-20260913-143916\agent'
|
||||
jeecgbootProxy = 'http://127.0.0.1:8180/jeecgboot/'
|
||||
updatedAt = (Get-Date).ToString('o')
|
||||
note = 'admin cutover Jeecg Vue + ledger 8180'
|
||||
}
|
||||
($obj | ConvertTo-Json) | Out-File -FilePath 'E:\wwwroot\ops\active-release.json' -Encoding utf8
|
||||
Get-Content 'E:\wwwroot\ops\active-release.json'
|
||||
Write-Output 'POINTER_OK'
|
||||
@@ -0,0 +1,34 @@
|
||||
# Force remove stale agent releases (not current)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$keepName = 'agent-20260913-210940'
|
||||
Get-ChildItem 'E:\wwwroot\agent\releases' | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$name = $_.Name
|
||||
$full = $_.FullName
|
||||
if ($name -eq $keepName) {
|
||||
Write-Output ('KEEP ' + $name)
|
||||
return
|
||||
}
|
||||
Write-Output ('CLEAN ' + $name)
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$cl = $_.CommandLine
|
||||
if ($cl -and $cl.Contains($full)) {
|
||||
Write-Output ('kill pid=' + $_.ProcessId)
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 1
|
||||
cmd /c ("rmdir /s /q `"$full`"") | Out-Null
|
||||
if (Test-Path $full) {
|
||||
try {
|
||||
Remove-Item $full -Recurse -Force -ErrorAction Stop
|
||||
Write-Output ('REMOVED_PS ' + $name)
|
||||
} catch {
|
||||
Write-Output ('FAIL ' + $name + ' ' + $_.Exception.Message)
|
||||
}
|
||||
} else {
|
||||
Write-Output ('REMOVED ' + $name)
|
||||
}
|
||||
}
|
||||
Write-Output '=== left ==='
|
||||
Get-ChildItem 'E:\wwwroot\agent\releases' | ForEach-Object { $_.Name }
|
||||
Write-Output 'FORCE_CLEAN_OK'
|
||||
@@ -0,0 +1,104 @@
|
||||
# Force true UTF-8 body (not GBK) with BOM; ASCII-only script
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$path = 'E:\wwwroot\.env'
|
||||
$bak = 'E:\wwwroot\.env.bak-encoding2-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
Copy-Item $path $bak -Force
|
||||
|
||||
function U([string]$escaped) {
|
||||
return [System.Text.RegularExpressions.Regex]::Unescape($escaped)
|
||||
}
|
||||
|
||||
# Read previous values using BOTH encodings; prefer lines that parse as KEY=VALUE ascii
|
||||
function Load-Map([byte[]]$bytes) {
|
||||
$map = [ordered]@{}
|
||||
foreach ($encName in @('utf-8', 'gbk')) {
|
||||
$enc = if ($encName -eq 'utf-8') { New-Object System.Text.UTF8Encoding $false } else { [System.Text.Encoding]::GetEncoding(936) }
|
||||
$start = 0
|
||||
if ($bytes.Length -ge 3 -and $bytes[0] -eq 239 -and $bytes[1] -eq 187 -and $bytes[2] -eq 191) { $start = 3 }
|
||||
$text = $enc.GetString($bytes, $start, $bytes.Length - $start)
|
||||
foreach ($line in ($text -split "`r?`n")) {
|
||||
$t = $line.Trim()
|
||||
if (-not $t -or $t.StartsWith('#')) { continue }
|
||||
$i = $t.IndexOf('=')
|
||||
if ($i -lt 1) { continue }
|
||||
$k = $t.Substring(0, $i).Trim()
|
||||
$v = $t.Substring($i + 1)
|
||||
if ($k -notmatch '^[A-Z0-9_]+$') { continue }
|
||||
if (-not $map.Contains($k)) { $map[$k] = $v }
|
||||
}
|
||||
}
|
||||
return $map
|
||||
}
|
||||
|
||||
$bytes = [System.IO.File]::ReadAllBytes($path)
|
||||
$map = Load-Map $bytes
|
||||
Write-Output ('keys=' + $map.Count)
|
||||
|
||||
$goodReply = U '\u8be2\u4ef7\u667a\u80fd\u4f53\u63a5\u901a\u6210\u529f\u3002\u5df2\u6536\u5230\uff1a{content}'
|
||||
$map['WECOM_CALLBACK_REPLY_TEXT'] = $goodReply
|
||||
|
||||
function V([string]$k, [string]$d = '') {
|
||||
if ($map.Contains($k)) { return [string]$map[$k] }
|
||||
return $d
|
||||
}
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
$lines.Add((U '# \u53cb\u901a\u8fbe\u8be2\u4ef7\u667a\u80fd\u4f53 \u00b7 \u73af\u5883\u53d8\u91cf\uff08E:\\wwwroot\\.env\uff09'))
|
||||
$lines.Add((U '# \u7f16\u7801 UTF-8 BOM\uff1b\u8bb0\u4e8b\u672c\u8bf7\u9009 UTF-8\u3002\u7981\u6b62\u63d0\u4ea4 Git\u3002'))
|
||||
$lines.Add((U '# \u5bf9\u7167 prompt/13'))
|
||||
$lines.Add('')
|
||||
$lines.Add('YTD_ENV=' + (V 'YTD_ENV' 'test'))
|
||||
$lines.Add('')
|
||||
$lines.Add((U '# \u516c\u7f51\u4e0e\u4f01\u5fae'))
|
||||
$lines.Add('PUBLIC_BASE_URL=' + (V 'PUBLIC_BASE_URL'))
|
||||
$lines.Add('WECOM_CALLBACK_URL=' + (V 'WECOM_CALLBACK_URL'))
|
||||
$lines.Add('INQUIRY_FORM_PUBLIC_BASE_URL=' + (V 'INQUIRY_FORM_PUBLIC_BASE_URL'))
|
||||
$lines.Add('WECOM_CORP_ID=' + (V 'WECOM_CORP_ID'))
|
||||
$lines.Add('WECOM_AGENT_ID=' + (V 'WECOM_AGENT_ID'))
|
||||
$lines.Add('WECOM_SECRET=' + (V 'WECOM_SECRET'))
|
||||
$lines.Add('WECOM_CALLBACK_TOKEN=' + (V 'WECOM_CALLBACK_TOKEN'))
|
||||
$lines.Add('WECOM_ENCODING_AES_KEY=' + (V 'WECOM_ENCODING_AES_KEY'))
|
||||
$lines.Add('WECOM_CALLBACK_REPLY_TEXT=' + $goodReply)
|
||||
$lines.Add('WECOM_OPS_AGENT_ID=' + (V 'WECOM_OPS_AGENT_ID'))
|
||||
$lines.Add('DEV_INJECT_ENABLED=' + (V 'DEV_INJECT_ENABLED' 'false'))
|
||||
$lines.Add('')
|
||||
$lines.Add((U '# \u5176\u4f59\u914d\u7f6e'))
|
||||
$skip = @('YTD_ENV','PUBLIC_BASE_URL','WECOM_CALLBACK_URL','INQUIRY_FORM_PUBLIC_BASE_URL','WECOM_CORP_ID','WECOM_AGENT_ID','WECOM_SECRET','WECOM_CALLBACK_TOKEN','WECOM_ENCODING_AES_KEY','WECOM_CALLBACK_REPLY_TEXT','WECOM_OPS_AGENT_ID','DEV_INJECT_ENABLED')
|
||||
foreach ($k in $map.Keys) {
|
||||
if ($skip -contains $k) { continue }
|
||||
$lines.Add($k + '=' + $map[$k])
|
||||
}
|
||||
|
||||
$text = [string]::Join("`r`n", $lines.ToArray()) + "`r`n"
|
||||
|
||||
# Write UTF-8 BOM explicitly via bytes
|
||||
$utf8 = New-Object System.Text.UTF8Encoding $false
|
||||
$body = $utf8.GetBytes($text)
|
||||
$bom = [byte[]](0xEF, 0xBB, 0xBF)
|
||||
$all = New-Object byte[] ($bom.Length + $body.Length)
|
||||
[Array]::Copy($bom, 0, $all, 0, 3)
|
||||
[Array]::Copy($body, 0, $all, 3, $body.Length)
|
||||
[System.IO.File]::WriteAllBytes($path, $all)
|
||||
|
||||
# Prove first Chinese char "友" is UTF-8 E5 8F 8B not GBK D3 D1
|
||||
$nb = [System.IO.File]::ReadAllBytes($path)
|
||||
# find first non-ascii after BOM
|
||||
$i = 3
|
||||
while ($i -lt $nb.Length -and $nb[$i] -lt 128) { $i++ }
|
||||
$hex = ($nb[$i..([Math]::Min($i+2, $nb.Length-1))] | ForEach-Object { $_.ToString('X2') }) -join ' '
|
||||
Write-Output ('first_non_ascii_hex=' + $hex)
|
||||
Write-Output ('expect_utf8_you=E5 8F 8B')
|
||||
Write-Output ('is_utf8_you=' + ($hex -eq 'E5 8F 8B'))
|
||||
|
||||
# decode check without console encoding issues: look for UTF-8 sequence of 询
|
||||
$needle = $utf8.GetBytes((U '\u8be2\u4ef7'))
|
||||
$found = $false
|
||||
for ($p = 0; $p -le $nb.Length - $needle.Length; $p++) {
|
||||
$ok = $true
|
||||
for ($j = 0; $j -lt $needle.Length; $j++) {
|
||||
if ($nb[$p + $j] -ne $needle[$j]) { $ok = $false; break }
|
||||
}
|
||||
if ($ok) { $found = $true; break }
|
||||
}
|
||||
Write-Output ('has_utf8_xunjia=' + $found)
|
||||
Write-Output 'ENV_UTF8_OK'
|
||||
@@ -0,0 +1,34 @@
|
||||
# 读取主账启动失败原因(不打印密钥)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$Err = 'E:\wwwroot\inquiry-new\logs\stderr.log'
|
||||
$Out = 'E:\wwwroot\inquiry-new\logs\stdout.log'
|
||||
Write-Output '=== process ==='
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -like '*inquiry-new*') {
|
||||
"pid=$($_.ProcessId) alive"
|
||||
}
|
||||
}
|
||||
Write-Output '=== port 8180 ==='
|
||||
$c = Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { "LISTEN pid=$($c.OwningProcess)" } else { 'down' }
|
||||
|
||||
Write-Output '=== stderr tail ==='
|
||||
if (Test-Path $Err) { Get-Content $Err -Tail 80 } else { 'no stderr' }
|
||||
Write-Output '=== stdout tail ==='
|
||||
if (Test-Path $Out) { Get-Content $Out -Tail 80 } else { 'no stdout' }
|
||||
|
||||
Write-Output '=== try health variants ==='
|
||||
foreach ($u in @(
|
||||
'http://127.0.0.1:8180/jeecgboot/',
|
||||
'http://127.0.0.1:8180/jeecg-boot/',
|
||||
'http://127.0.0.1:8180/actuator/health',
|
||||
'http://127.0.0.1:8180/'
|
||||
)) {
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri $u -UseBasicParsing -TimeoutSec 5
|
||||
"OK $($r.StatusCode) $u"
|
||||
} catch {
|
||||
"FAIL $u"
|
||||
}
|
||||
}
|
||||
Write-Output 'LEDGER_DIAG_DONE'
|
||||
@@ -0,0 +1,16 @@
|
||||
# 主账 8180 掉线诊断
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== java inquiry-new ==='
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -like '*inquiry-new*' -or $_.CommandLine -like '*8180*') {
|
||||
"pid=$($_.ProcessId) $($_.CommandLine.Substring(0, [Math]::Min(180, $_.CommandLine.Length)))"
|
||||
}
|
||||
}
|
||||
Write-Output '=== recent stdout error lines ==='
|
||||
Select-String -Path 'E:\wwwroot\inquiry-new\logs\stdout.log' -Pattern 'ERROR|Exception|Started Jeecg|Tomcat started|APPLICATION FAILED' |
|
||||
Select-Object -Last 20 |
|
||||
ForEach-Object { $_.Line.Substring(0, [Math]::Min(220, $_.Line.Length)) }
|
||||
Write-Output '=== file times ==='
|
||||
Get-Item 'E:\wwwroot\inquiry-new\logs\stdout.log','E:\wwwroot\inquiry-new\logs\stderr.log' |
|
||||
ForEach-Object { "$($_.Name) len=$($_.Length) mtime=$($_.LastWriteTime)" }
|
||||
Write-Output 'DIAG2_DONE'
|
||||
@@ -0,0 +1,6 @@
|
||||
# 抓主账 stdout 里最后一次失败的根因
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$log = 'E:\wwwroot\inquiry-new\logs\stdout.log'
|
||||
Write-Output '=== last 80 ==='
|
||||
Get-Content $log -Tail 80
|
||||
Write-Output 'TAIL_DONE'
|
||||
@@ -0,0 +1,40 @@
|
||||
# List wwwroot layout for cleanup decision
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== tree tops ==='
|
||||
@(
|
||||
'E:\wwwroot',
|
||||
'E:\wwwroot\agent',
|
||||
'E:\wwwroot\admin',
|
||||
'E:\wwwroot\inquiry-new',
|
||||
'E:\wwwroot\ops',
|
||||
'E:\wwwroot\agent\current',
|
||||
'E:\wwwroot\admin\current'
|
||||
) | ForEach-Object { "$_ => $(Test-Path $_)" }
|
||||
|
||||
Write-Output '=== agent releases ==='
|
||||
if (Test-Path 'E:\wwwroot\agent\releases') {
|
||||
Get-ChildItem 'E:\wwwroot\agent\releases' | ForEach-Object { $_.Name + ' ' + $_.LastWriteTime }
|
||||
} else { 'missing agent/releases' }
|
||||
|
||||
Write-Output '=== admin releases ==='
|
||||
if (Test-Path 'E:\wwwroot\admin\releases') {
|
||||
Get-ChildItem 'E:\wwwroot\admin\releases' | ForEach-Object { $_.Name + ' ' + $_.LastWriteTime }
|
||||
} else { 'missing admin/releases' }
|
||||
|
||||
Write-Output '=== current targets ==='
|
||||
foreach ($p in @('E:\wwwroot\agent\current','E:\wwwroot\admin\current')) {
|
||||
if (Test-Path $p) {
|
||||
$i = Get-Item $p -Force
|
||||
"link=$p type=$($i.LinkType) target=$($i.Target)"
|
||||
} else { "missing $p" }
|
||||
}
|
||||
|
||||
Write-Output '=== pointer ==='
|
||||
if (Test-Path 'E:\wwwroot\ops\active-release.json') { Get-Content 'E:\wwwroot\ops\active-release.json' -Raw }
|
||||
|
||||
Write-Output '=== ports ==='
|
||||
foreach ($x in 8080,8810,8813,8180,8910,8913) {
|
||||
$c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { "$x LISTEN pid=$($c.OwningProcess)" } else { "$x down" }
|
||||
}
|
||||
Write-Output 'LIST_DONE'
|
||||
@@ -0,0 +1,26 @@
|
||||
# Check captcha flag on running ledger + try login without captcha
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== overlay jeecg.loginCaptcha ==='
|
||||
Select-String -Path 'E:\wwwroot\inquiry-new\config\application-inquiry.yml' -Pattern 'loginCaptcha|cas:' | ForEach-Object { $_.Line }
|
||||
Write-Output '=== process cmdline ==='
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -like '*inquiry-new*') {
|
||||
$_.CommandLine.Substring(0, [Math]::Min(300, $_.CommandLine.Length))
|
||||
}
|
||||
}
|
||||
Write-Output '=== login attempt ==='
|
||||
$body = '{"username":"admin","password":"123456","captcha":"","checkKey":""}'
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/login' -Method POST -Body $body -ContentType 'application/json;charset=UTF-8' -UseBasicParsing -TimeoutSec 15
|
||||
"status=$($r.StatusCode) body=$($r.Content.Substring(0,[Math]::Min(300,$r.Content.Length)))"
|
||||
} catch {
|
||||
$resp = $_.Exception.Response
|
||||
if ($resp) {
|
||||
$reader = New-Object System.IO.StreamReader($resp.GetResponseStream())
|
||||
$txt = $reader.ReadToEnd()
|
||||
"http_err status=$([int]$resp.StatusCode) body=$txt"
|
||||
} else {
|
||||
"err=$($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
Write-Output 'CAPTCHA_CHECK_DONE'
|
||||
@@ -0,0 +1,27 @@
|
||||
# 切后台后验收:静态是否为 Jeecg、API 是否走 8180
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== admin index sniff ==='
|
||||
$idx = 'E:\wwwroot\admin\current\index.html'
|
||||
if (Test-Path $idx) {
|
||||
Get-Content $idx -TotalCount 30
|
||||
}
|
||||
Write-Output '=== app config ==='
|
||||
$cfg = Get-ChildItem 'E:\wwwroot\admin\current' -Filter '_app.config.js' -Recurse -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($cfg) {
|
||||
Select-String -Path $cfg.FullName -Pattern 'VITE_GLOB|jeecgboot|8080|8180|ai.ytd' | ForEach-Object { $_.Line.Substring(0, [Math]::Min(200, $_.Line.Length)) }
|
||||
}
|
||||
Write-Output '=== nginx targets ==='
|
||||
Select-String -Path 'C:\nginx-1.18.0\myconf\ai.conf' -Pattern 'proxy_pass|root ' | ForEach-Object { $_.Line.Trim() }
|
||||
Write-Output '=== ports ==='
|
||||
foreach ($x in 8080,8180,8910) {
|
||||
$c = Get-NetTCPConnection -LocalPort $x -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { "$x LISTEN" } else { "$x down" }
|
||||
}
|
||||
Write-Output '=== local proxy smoke ==='
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/aftercut' -UseBasicParsing -TimeoutSec 8
|
||||
"8180 ok $($r.StatusCode)"
|
||||
} catch { "8180 fail" }
|
||||
Write-Output '=== pointer ==='
|
||||
Get-Content 'E:\wwwroot\ops\active-release.json' -Raw
|
||||
Write-Output 'POST_CUT_OK'
|
||||
@@ -0,0 +1,45 @@
|
||||
# 探测新系统智能体 8910 日志落点(只读,不改现网)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== 8910 listen ==='
|
||||
$c = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($c) {
|
||||
Write-Output ("LISTEN pid=" + $c.OwningProcess)
|
||||
} else {
|
||||
Write-Output '8910 not listening'
|
||||
}
|
||||
|
||||
Write-Output '=== health ==='
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 5
|
||||
Write-Output $h.Content
|
||||
} catch {
|
||||
Write-Output ('health_fail: ' + $_.Exception.Message)
|
||||
}
|
||||
|
||||
Write-Output '=== candidate log dirs ==='
|
||||
$dirs = @(
|
||||
'E:\wwwroot\logs',
|
||||
'E:\wwwroot\agent\logs',
|
||||
'E:\wwwroot\agent\current',
|
||||
'C:\ytd-test\logs'
|
||||
)
|
||||
foreach ($d in $dirs) {
|
||||
if (Test-Path $d) {
|
||||
Write-Output ("DIR_OK " + $d)
|
||||
Get-ChildItem $d -File -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -match 'agent|wecom|http|worker' } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 20 |
|
||||
ForEach-Object { Write-Output (" " + $_.FullName + " bytes=" + $_.Length + " mtime=" + $_.LastWriteTime.ToString('s')) }
|
||||
} else {
|
||||
Write-Output ("DIR_MISSING " + $d)
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output '=== python cmdlines matching wwwroot/agent ==='
|
||||
Get-CimInstance Win32_Process -Filter "Name='python.exe'" -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
$cl = $_.CommandLine
|
||||
if ($null -ne $cl -and ($cl -match 'wwwroot|agent\.http_app|8910')) {
|
||||
Write-Output ("pid=" + $_.ProcessId + " " + $cl)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
# 快速看 8180 是否还活着
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$c = Get-NetTCPConnection -LocalPort 8180 -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { "8180 LISTEN pid=$($c.OwningProcess)" } else { '8180 down' }
|
||||
$p8080 = Get-NetTCPConnection -LocalPort 8080 -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($p8080) { "8080 LISTEN pid=$($p8080.OwningProcess)" } else { '8080 down' }
|
||||
$p8910 = Get-NetTCPConnection -LocalPort 8910 -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($p8910) { "8910 LISTEN pid=$($p8910.OwningProcess)" } else { '8910 down' }
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/deploycheck' -UseBasicParsing -TimeoutSec 8
|
||||
"8180_randomImage=$($r.StatusCode) bytes=$($r.RawContentLength)"
|
||||
} catch {
|
||||
$code = $null
|
||||
try { $code = $_.Exception.Response.StatusCode.value__ } catch {}
|
||||
"8180_randomImage FAIL code=$code msg=$($_.Exception.Message)"
|
||||
}
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -like '*inquiry-new*') { "java pid=$($_.ProcessId) alive" }
|
||||
}
|
||||
Write-Output 'QUICK_DONE'
|
||||
@@ -0,0 +1,62 @@
|
||||
# 用本机 Python 测 Redis AUTH(只打印 PONG/错误类型,不打印口令)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
Get-Content 'E:\wwwroot\.env' | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith('#')) { return }
|
||||
$i = $line.IndexOf('=')
|
||||
if ($i -lt 1) { return }
|
||||
Set-Item -Path ("Env:" + $line.Substring(0,$i).Trim()) -Value $line.Substring($i+1).Trim().Trim('"')
|
||||
}
|
||||
$py = 'C:\yutongda\tools\Python312\python.exe'
|
||||
$code = @'
|
||||
import os, socket, sys
|
||||
url = os.environ.get("REDIS_URL","")
|
||||
host = os.environ.get("YTD_REDIS_HOST","10.206.0.14")
|
||||
port = int(os.environ.get("YTD_REDIS_PORT","6379"))
|
||||
user = os.environ.get("YTD_REDIS_USERNAME","")
|
||||
pwd = os.environ.get("YTD_REDIS_PASSWORD","")
|
||||
# parse url if needed
|
||||
if url.startswith("redis://"):
|
||||
rest = url[len("redis://"):]
|
||||
if "@" in rest:
|
||||
cred, hp = rest.split("@",1)
|
||||
if ":" in cred:
|
||||
u,p = cred.split(":",1)
|
||||
if u: user = u
|
||||
pwd = p
|
||||
else:
|
||||
pwd = cred
|
||||
host = hp.split("/")[0].split(":")[0]
|
||||
if ":" in hp.split("/")[0]:
|
||||
port = int(hp.split("/")[0].split(":")[1])
|
||||
|
||||
def send(auth_cmd):
|
||||
s = socket.create_connection((host, port), 5)
|
||||
s.sendall(auth_cmd)
|
||||
data = s.recv(256)
|
||||
s.close()
|
||||
return data
|
||||
|
||||
# RESP AUTH
|
||||
def resp_auth_password(p):
|
||||
p = p.encode()
|
||||
return b"*2\r\n$4\r\nAUTH\r\n$%d\r\n%s\r\n" % (len(p), p)
|
||||
|
||||
def resp_auth_user(u, p):
|
||||
u=u.encode(); p=p.encode()
|
||||
return b"*3\r\n$4\r\nAUTH\r\n$%d\r\n%s\r\n$%d\r\n%s\r\n" % (len(u), u, len(p), p)
|
||||
|
||||
print("host=%s port=%s user_len=%s pwd_len=%s" % (host, port, len(user or ""), len(pwd or "")))
|
||||
try:
|
||||
r1 = send(resp_auth_password(pwd or ""))
|
||||
print("auth_password:", r1[:80])
|
||||
except Exception as e:
|
||||
print("auth_password_err", type(e).__name__, str(e)[:80])
|
||||
if user:
|
||||
try:
|
||||
r2 = send(resp_auth_user(user, pwd or ""))
|
||||
print("auth_user:", r2[:80])
|
||||
except Exception as e:
|
||||
print("auth_user_err", type(e).__name__, str(e)[:80])
|
||||
'@
|
||||
$code | & $py -
|
||||
@@ -0,0 +1,45 @@
|
||||
# Redis connectivity probe (no secrets printed)
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Get-Content 'E:\wwwroot\.env' | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith('#')) { return }
|
||||
$i = $line.IndexOf('=')
|
||||
if ($i -lt 1) { return }
|
||||
$k = $line.Substring(0, $i).Trim()
|
||||
$v = $line.Substring($i + 1).Trim().Trim('"')
|
||||
if ($k -match 'REDIS|YTD_REDIS') {
|
||||
if ($k -match 'PASSWORD|URL') {
|
||||
Write-Output ("$k len=" + $v.Length + " has_at=" + ($v -match '@'))
|
||||
} else {
|
||||
Write-Output ("$k=$v")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output '=== local 6379 ==='
|
||||
$c = Get-NetTCPConnection -LocalPort 6379 -State Listen -EA SilentlyContinue | Select-Object -First 1
|
||||
if ($c) { "127.0.0.1:6379 LISTEN pid=$($c.OwningProcess)" } else { 'local 6379 down' }
|
||||
|
||||
$redisCli = @(
|
||||
'C:\yutongda\tools\Redis\redis-cli.exe',
|
||||
'C:\Program Files\Redis\redis-cli.exe',
|
||||
'E:\wwwroot\tools\redis-cli.exe'
|
||||
) | Where-Object { Test-Path $_ } | Select-Object -First 1
|
||||
|
||||
Write-Output ("redis-cli=" + $redisCli)
|
||||
|
||||
# try ping local without auth
|
||||
if ($redisCli) {
|
||||
& $redisCli -h 127.0.0.1 -p 6379 PING 2>&1 | ForEach-Object { "local_nopass: $_" }
|
||||
}
|
||||
|
||||
# try data host from env if present
|
||||
$hostLine = (Get-Content 'E:\wwwroot\.env' | Where-Object { $_ -match '^YTD_REDIS_HOST=' } | Select-Object -First 1)
|
||||
if ($hostLine) {
|
||||
$rh = $hostLine.Split('=',2)[1].Trim().Trim('"')
|
||||
Write-Output ("env_host=$rh")
|
||||
if ($redisCli -and $rh -and $rh -ne '127.0.0.1') {
|
||||
& $redisCli -h $rh -p 6379 PING 2>&1 | ForEach-Object { "remote_nopass: $_" }
|
||||
}
|
||||
}
|
||||
Write-Output 'REDIS_PROBE_DONE'
|
||||
@@ -0,0 +1,29 @@
|
||||
# TCP 探测 Redis,不打印口令
|
||||
$ErrorActionPreference = 'Continue'
|
||||
function Test-Port($h, $p) {
|
||||
try {
|
||||
$tcp = New-Object System.Net.Sockets.TcpClient
|
||||
$iar = $tcp.BeginConnect($h, [int]$p, $null, $null)
|
||||
$ok = $iar.AsyncWaitHandle.WaitOne(3000, $false)
|
||||
if (-not $ok) { $tcp.Close(); return "TIMEOUT $h`:$p" }
|
||||
$tcp.EndConnect($iar)
|
||||
$tcp.Close()
|
||||
return "OPEN $h`:$p"
|
||||
} catch {
|
||||
return "FAIL $h`:$p $($_.Exception.Message)"
|
||||
}
|
||||
}
|
||||
Write-Output (Test-Port '127.0.0.1' 6379)
|
||||
Write-Output (Test-Port '10.206.0.14' 6379)
|
||||
Write-Output (Test-Port '10.206.0.15' 6379)
|
||||
Write-Output (Test-Port '10.206.0.14' 3306)
|
||||
|
||||
# 从 REDIS_URL 解析 host
|
||||
$raw = (Get-Content 'E:\wwwroot\.env' | Where-Object { $_ -match '^REDIS_URL=' } | Select-Object -First 1)
|
||||
if ($raw) {
|
||||
if ($raw -match '@([^:/]+):(\d+)/(\d+)') {
|
||||
Write-Output ("url_host=" + $Matches[1] + " port=" + $Matches[2] + " db=" + $Matches[3])
|
||||
Write-Output (Test-Port $Matches[1] $Matches[2])
|
||||
}
|
||||
}
|
||||
Write-Output 'TCP_DONE'
|
||||
@@ -0,0 +1,90 @@
|
||||
# Kill duplicate agent wrappers; keep newest; then remove stale release dirs
|
||||
$ErrorActionPreference = 'Continue'
|
||||
$Root = 'E:\wwwroot'
|
||||
$keepRel = 'agent-20260913-210940'
|
||||
$keepHome = Join-Path $Root ("agent\releases\" + $keepRel + "\agent")
|
||||
|
||||
Write-Output '=== stop ALL agent cmd/python under wwwroot ==='
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
$cl = $_.CommandLine
|
||||
if (-not $cl) { return }
|
||||
$hit = $false
|
||||
if ($cl -like '*run-agent-http.cmd*' -or $cl -like '*run-agent-worker.cmd*') { $hit = $true }
|
||||
if ($cl -like '*agent.http_app*' -or $cl -like '*agent.worker_app*') { $hit = $true }
|
||||
if ($cl -like '*wwwroot\agent*') { $hit = $true }
|
||||
if ($hit) {
|
||||
Write-Output ("stop pid=$($_.ProcessId) $($_.Name)")
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
# also free 8910
|
||||
Get-NetTCPConnection -LocalPort 8910 -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique |
|
||||
ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
|
||||
Start-Sleep -Seconds 3
|
||||
|
||||
Write-Output '=== delete stale releases ==='
|
||||
Get-ChildItem (Join-Path $Root 'agent\releases') | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
if ($_.Name -eq $keepRel) {
|
||||
Write-Output ('KEEP ' + $_.Name)
|
||||
} else {
|
||||
$full = $_.FullName
|
||||
cmd /c ("rmdir /s /q `"$full`"") | Out-Null
|
||||
if (Test-Path $full) {
|
||||
Write-Output ('STILL ' + $_.Name)
|
||||
} else {
|
||||
Write-Output ('GONE ' + $_.Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Write-Output '=== rewrite run scripts to keepHome ==='
|
||||
$venvPy = Join-Path $keepHome '.venv\Scripts\python.exe'
|
||||
$runHttp = @(
|
||||
'@echo off',
|
||||
('cd /d "' + $keepHome + '"'),
|
||||
'set YTD_ENV=test',
|
||||
'set AGENT_HTTP_PORT=8910',
|
||||
'set PYTHONUTF8=1',
|
||||
':loop',
|
||||
('"' + $venvPy + '" -m agent.http_app >> "' + $Root + '\logs\agent-http.out.log" 2>> "' + $Root + '\logs\agent-http.err.log"'),
|
||||
('echo agent exited >> "' + $Root + '\logs\agent-http.err.log"'),
|
||||
'timeout /t 3 /nobreak >nul',
|
||||
'goto loop'
|
||||
) -join "`r`n"
|
||||
Set-Content -Path (Join-Path $Root 'ops\run-agent-http.cmd') -Value $runHttp -Encoding ASCII
|
||||
|
||||
$runWorker = @(
|
||||
'@echo off',
|
||||
('cd /d "' + $keepHome + '"'),
|
||||
'set YTD_ENV=test',
|
||||
'set PYTHONUTF8=1',
|
||||
':loop',
|
||||
('"' + $venvPy + '" -m agent.worker_app >> "' + $Root + '\logs\agent-worker.out.log" 2>> "' + $Root + '\logs\agent-worker.err.log"'),
|
||||
('echo worker exited >> "' + $Root + '\logs\agent-worker.err.log"'),
|
||||
'timeout /t 3 /nobreak >nul',
|
||||
'goto loop'
|
||||
) -join "`r`n"
|
||||
Set-Content -Path (Join-Path $Root 'ops\run-agent-worker.cmd') -Value $runWorker -Encoding ASCII
|
||||
|
||||
# ensure junction
|
||||
$current = Join-Path $Root 'agent\current'
|
||||
if (Test-Path $current) { cmd /c ("rmdir `"$current`"") | Out-Null }
|
||||
cmd /c ("mklink /J `"$current`" `"$keepHome`"") | Out-Null
|
||||
|
||||
Write-Output '=== start one http + one worker ==='
|
||||
$w1 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('cmd.exe /c "' + (Join-Path $Root 'ops\run-agent-http.cmd') + '"') }
|
||||
$w2 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{ CommandLine = ('cmd.exe /c "' + (Join-Path $Root 'ops\run-agent-worker.cmd') + '"') }
|
||||
Write-Output ("http_wmi=" + $w1.ReturnValue + " worker_wmi=" + $w2.ReturnValue)
|
||||
Start-Sleep -Seconds 6
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 8
|
||||
Write-Output ('HEALTH=' + $h.StatusCode + ' ' + $h.Content)
|
||||
} catch {
|
||||
Write-Output ('health_fail ' + $_)
|
||||
Get-Content (Join-Path $Root 'logs\agent-http.err.log') -Tail 30 -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
Write-Output '=== left releases ==='
|
||||
Get-ChildItem (Join-Path $Root 'agent\releases') | ForEach-Object { $_.Name }
|
||||
Write-Output 'REPAIR_OK'
|
||||
@@ -1,33 +1,28 @@
|
||||
# 安全发布:新 release + 切 agent\current,重启 8910;禁止改 Nginx / C:\yutongda
|
||||
# 入参:incoming\agent-release.zip 已就位;可选 env.file 覆盖 E:\wwwroot\.env
|
||||
# Safe agent deploy: new release, switch junction, restart 8910+worker. No nginx / no C:\yutongda.
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$ReleaseId = 'agent-' + (Get-Date -Format 'yyyyMMdd-HHmmss')
|
||||
$Root = 'E:\wwwroot'
|
||||
$Release = Join-Path $Root "agent\releases\$ReleaseId"
|
||||
$Release = Join-Path $Root ("agent\releases\" + $ReleaseId)
|
||||
$AgentCurrent = Join-Path $Root 'agent\current'
|
||||
$Python = 'C:\yutongda\tools\Python312\python.exe'
|
||||
$Zip = 'C:\Users\ytd_extdev_0903\incoming\agent-release.zip'
|
||||
$EnvSrc = 'C:\Users\ytd_extdev_0903\incoming\env.file'
|
||||
|
||||
Write-Output "=== create dirs release=$ReleaseId ==="
|
||||
New-Item -ItemType Directory -Force -Path $Release, "$Root\logs", "$Root\ops", "$Root\agent\releases" | Out-Null
|
||||
if (-not (Test-Path $Zip)) { throw "missing $Zip" }
|
||||
if (-not (Test-Path $Python)) { throw "missing $Python" }
|
||||
Write-Output ("=== create dirs release=" + $ReleaseId + " ===")
|
||||
New-Item -ItemType Directory -Force -Path $Release, (Join-Path $Root 'logs'), (Join-Path $Root 'ops'), (Join-Path $Root 'agent\releases') | Out-Null
|
||||
if (-not (Test-Path $Zip)) { throw 'missing agent-release.zip' }
|
||||
if (-not (Test-Path $Python)) { throw 'missing python' }
|
||||
|
||||
Write-Output '=== expand ==='
|
||||
Expand-Archive -Path $Zip -DestinationPath $Release -Force
|
||||
$AgentHome = Join-Path $Release 'agent'
|
||||
if (-not (Test-Path (Join-Path $AgentHome 'agent\http_app.py'))) {
|
||||
throw "http_app.py not found under $AgentHome"
|
||||
}
|
||||
$HttpApp = Join-Path $AgentHome 'agent\http_app.py'
|
||||
if (-not (Test-Path $HttpApp)) { throw 'http_app.py not found' }
|
||||
|
||||
Write-Output '=== env ==='
|
||||
if (Test-Path $EnvSrc) {
|
||||
Copy-Item $EnvSrc (Join-Path $Root '.env') -Force
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $Root '.env'))) { throw 'missing E:\wwwroot\.env' }
|
||||
Copy-Item (Join-Path $Root '.env') (Join-Path $Release '.env') -Force
|
||||
Copy-Item (Join-Path $Root '.env') (Join-Path $AgentHome '.env') -Force
|
||||
$EnvFile = Join-Path $Root '.env'
|
||||
if (-not (Test-Path $EnvFile)) { throw 'missing E:\wwwroot\.env' }
|
||||
Copy-Item $EnvFile (Join-Path $Release '.env') -Force
|
||||
Copy-Item $EnvFile (Join-Path $AgentHome '.env') -Force
|
||||
|
||||
Write-Output '=== venv + pip ==='
|
||||
$venvDir = Join-Path $AgentHome '.venv'
|
||||
@@ -37,93 +32,111 @@ if (-not (Test-Path $venvPy)) {
|
||||
& $Python -m venv $venvDir
|
||||
if ($LASTEXITCODE -ne 0) { throw 'venv failed' }
|
||||
}
|
||||
& $venvPip install --default-timeout=180 -i https://pypi.tuna.tsinghua.edu.cn/simple -r (Join-Path $AgentHome 'requirements.txt')
|
||||
# Windows Chinese locale: force UTF-8 so requirements.txt comments decode
|
||||
$env:PYTHONUTF8 = '1'
|
||||
$env:PYTHONIOENCODING = 'utf-8'
|
||||
$Req = Join-Path $AgentHome 'requirements.txt'
|
||||
& $venvPip install --default-timeout=180 -i https://pypi.tuna.tsinghua.edu.cn/simple -r $Req
|
||||
if ($LASTEXITCODE -ne 0) { throw 'pip install failed' }
|
||||
|
||||
Write-Output '=== switch junction ==='
|
||||
if (Test-Path $AgentCurrent) {
|
||||
cmd /c "rmdir `"$AgentCurrent`"" | Out-Null
|
||||
cmd /c ("rmdir `"" + $AgentCurrent + "`"") | Out-Null
|
||||
if (Test-Path $AgentCurrent) { Remove-Item $AgentCurrent -Recurse -Force }
|
||||
}
|
||||
cmd /c "mklink /J `"$AgentCurrent`" `"$AgentHome`""
|
||||
cmd /c ("mklink /J `"" + $AgentCurrent + "`" `"" + $AgentHome + "`"")
|
||||
if (-not (Test-Path (Join-Path $AgentCurrent 'agent\http_app.py'))) { throw 'agent current missing http_app' }
|
||||
|
||||
Write-Output '=== stop old 8910 only ==='
|
||||
Get-NetTCPConnection -LocalPort 8910 -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique |
|
||||
ForEach-Object { Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue }
|
||||
# 停旧 worker(仅匹配 wwwroot agent)
|
||||
|
||||
Get-CimInstance Win32_Process -Filter "Name='python.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -and $_.CommandLine -like '*E:\wwwroot\agent*' -and $_.CommandLine -like '*worker_app*') {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
$cl = $_.CommandLine
|
||||
if ($null -ne $cl) {
|
||||
if (($cl -like '*wwwroot*agent*') -and ($cl -like '*worker_app*')) {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
Write-Output '=== write run scripts ==='
|
||||
$runHttp = @"
|
||||
@echo off
|
||||
cd /d "$AgentCurrent"
|
||||
set YTD_ENV=test
|
||||
set AGENT_HTTP_PORT=8910
|
||||
:loop
|
||||
"$AgentCurrent\.venv\Scripts\python.exe" -m agent.http_app >> "$Root\logs\agent-http.out.log" 2>> "$Root\logs\agent-http.err.log"
|
||||
echo agent exited, restart in 3s >> "$Root\logs\agent-http.err.log"
|
||||
timeout /t 3 /nobreak >nul
|
||||
goto loop
|
||||
"@
|
||||
Set-Content -Path (Join-Path $Root 'ops\run-agent-http.cmd') -Value $runHttp -Encoding ASCII
|
||||
$runHttpPath = Join-Path $Root 'ops\run-agent-http.cmd'
|
||||
$runWorkerPath = Join-Path $Root 'ops\run-agent-worker.cmd'
|
||||
$logHttpOut = Join-Path $Root 'logs\agent-http.out.log'
|
||||
$logHttpErr = Join-Path $Root 'logs\agent-http.err.log'
|
||||
$logWorkerOut = Join-Path $Root 'logs\agent-worker.out.log'
|
||||
$logWorkerErr = Join-Path $Root 'logs\agent-worker.err.log'
|
||||
|
||||
$runWorker = @"
|
||||
@echo off
|
||||
cd /d "$AgentCurrent"
|
||||
set YTD_ENV=test
|
||||
:loop
|
||||
"$AgentCurrent\.venv\Scripts\python.exe" -m agent.worker_app >> "$Root\logs\agent-worker.out.log" 2>> "$Root\logs\agent-worker.err.log"
|
||||
echo worker exited, restart in 3s >> "$Root\logs\agent-worker.err.log"
|
||||
timeout /t 3 /nobreak >nul
|
||||
goto loop
|
||||
"@
|
||||
Set-Content -Path (Join-Path $Root 'ops\run-agent-worker.cmd') -Value $runWorker -Encoding ASCII
|
||||
$runHttp = @(
|
||||
'@echo off',
|
||||
('cd /d "' + $AgentCurrent + '"'),
|
||||
'set YTD_ENV=test',
|
||||
'set AGENT_HTTP_PORT=8910',
|
||||
':loop',
|
||||
('"' + $venvPy + '" -m agent.http_app >> "' + $logHttpOut + '" 2>> "' + $logHttpErr + '"'),
|
||||
('echo agent exited, restart in 3s >> "' + $logHttpErr + '"'),
|
||||
'timeout /t 3 /nobreak >nul',
|
||||
'goto loop'
|
||||
) -join "`r`n"
|
||||
Set-Content -Path $runHttpPath -Value $runHttp -Encoding ASCII
|
||||
|
||||
$runWorker = @(
|
||||
'@echo off',
|
||||
('cd /d "' + $AgentCurrent + '"'),
|
||||
'set YTD_ENV=test',
|
||||
':loop',
|
||||
('"' + $venvPy + '" -m agent.worker_app >> "' + $logWorkerOut + '" 2>> "' + $logWorkerErr + '"'),
|
||||
('echo worker exited, restart in 3s >> "' + $logWorkerErr + '"'),
|
||||
'timeout /t 3 /nobreak >nul',
|
||||
'goto loop'
|
||||
) -join "`r`n"
|
||||
Set-Content -Path $runWorkerPath -Value $runWorker -Encoding ASCII
|
||||
|
||||
Write-Output '=== start http + worker via WMI ==='
|
||||
$w1 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = "cmd.exe /c `"$(Join-Path $Root 'ops\run-agent-http.cmd')`""
|
||||
CommandLine = ('cmd.exe /c "' + $runHttpPath + '"')
|
||||
}
|
||||
Write-Output "HTTP_WMI=$($w1.ReturnValue) pid=$($w1.ProcessId)"
|
||||
Write-Output ("HTTP_WMI=" + $w1.ReturnValue + " pid=" + $w1.ProcessId)
|
||||
$w2 = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = "cmd.exe /c `"$(Join-Path $Root 'ops\run-agent-worker.cmd')`""
|
||||
CommandLine = ('cmd.exe /c "' + $runWorkerPath + '"')
|
||||
}
|
||||
Write-Output "WORKER_WMI=$($w2.ReturnValue) pid=$($w2.ProcessId)"
|
||||
Write-Output ("WORKER_WMI=" + $w2.ReturnValue + " pid=" + $w2.ProcessId)
|
||||
Start-Sleep -Seconds 8
|
||||
|
||||
try {
|
||||
$h = Invoke-WebRequest -Uri 'http://127.0.0.1:8910/health' -UseBasicParsing -TimeoutSec 8
|
||||
Write-Output "HEALTH=$($h.StatusCode) $($h.Content)"
|
||||
Write-Output ("HEALTH=" + $h.StatusCode + " " + $h.Content)
|
||||
} catch {
|
||||
Get-Content (Join-Path $Root 'logs\agent-http.err.log') -Tail 40 -ErrorAction SilentlyContinue
|
||||
throw "agent health failed: $_"
|
||||
if (Test-Path $logHttpErr) { Get-Content $logHttpErr -Tail 40 }
|
||||
throw ('agent health failed: ' + $_)
|
||||
}
|
||||
|
||||
$listenPid = 0
|
||||
$listen = Get-NetTCPConnection -LocalPort 8910 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($listen) { $listenPid = $listen.OwningProcess }
|
||||
|
||||
$prevAdmin = $null
|
||||
if (Test-Path (Join-Path $Root 'ops\active-release.json')) {
|
||||
try { $prevAdmin = (Get-Content (Join-Path $Root 'ops\active-release.json') -Raw | ConvertFrom-Json).adminCurrent } catch {}
|
||||
$prevAdmin = Join-Path $Root 'admin\current'
|
||||
$ptrPath = Join-Path $Root 'ops\active-release.json'
|
||||
if (Test-Path $ptrPath) {
|
||||
try {
|
||||
$old = Get-Content $ptrPath -Raw | ConvertFrom-Json
|
||||
if ($old.adminCurrent) { $prevAdmin = $old.adminCurrent }
|
||||
} catch {}
|
||||
}
|
||||
|
||||
@{
|
||||
$ptr = @{
|
||||
root = $Root
|
||||
releaseId = $ReleaseId
|
||||
releaseRoot = $Release
|
||||
agentHome = $AgentHome
|
||||
agentCurrent = $AgentCurrent
|
||||
adminCurrent = $(if ($prevAdmin) { $prevAdmin } else { Join-Path $Root 'admin\current' })
|
||||
adminCurrent = $prevAdmin
|
||||
pid = $listenPid
|
||||
updatedAt = (Get-Date).ToString('o')
|
||||
note = 'safe deploy: no nginx change, no C:\yutongda touch'
|
||||
} | ConvertTo-Json | Set-Content (Join-Path $Root 'ops\active-release.json') -Encoding UTF8
|
||||
|
||||
note = 'safe deploy: no nginx change'
|
||||
}
|
||||
$ptr | ConvertTo-Json | Set-Content $ptrPath -Encoding UTF8
|
||||
Write-Output 'AGENT_SAFE_DEPLOY_OK'
|
||||
|
||||
@@ -1,96 +1,87 @@
|
||||
# 发布并启动新主账 8180(仅 E:\wwwroot\inquiry-new);禁止动现网 8080 / C:\yutongda
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = 'E:\wwwroot\inquiry-new'
|
||||
$Jar = Join-Path $Root 'jeecg-system-start-3.5.3.jar'
|
||||
$LogDir = Join-Path $Root 'logs'
|
||||
$IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar'
|
||||
New-Item -ItemType Directory -Force -Path $Root, $LogDir | Out-Null
|
||||
# Start ledger 8180 with config overlay; never touch prod 8080 / C:\yutongda
|
||||
$ErrorActionPreference = "Stop"
|
||||
$Root = "E:\wwwroot\inquiry-new"
|
||||
$Jar = Join-Path $Root "jeecg-system-start-3.5.3.jar"
|
||||
$LogDir = Join-Path $Root "logs"
|
||||
$CfgDir = Join-Path $Root "config"
|
||||
$IncomingJar = "C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar"
|
||||
$IncomingCfg = "C:\Users\ytd_extdev_0903\incoming\ledger-config.zip"
|
||||
New-Item -ItemType Directory -Force -Path $Root, $LogDir, $CfgDir | Out-Null
|
||||
|
||||
if (Test-Path $IncomingJar) {
|
||||
Write-Output "=== replace jar from incoming ==="
|
||||
Copy-Item $IncomingJar $Jar -Force
|
||||
if (Test-Path $IncomingJar) { Copy-Item $IncomingJar $Jar -Force }
|
||||
if (-not (Test-Path $Jar)) { throw "jar missing" }
|
||||
if (Test-Path $IncomingCfg) {
|
||||
Expand-Archive -Path $IncomingCfg -DestinationPath $CfgDir -Force
|
||||
}
|
||||
if (-not (Test-Path (Join-Path $CfgDir "application-inquiry.yml"))) {
|
||||
@"
|
||||
cas:
|
||||
prefixUrl: http://127.0.0.1/cas-disabled
|
||||
justauth:
|
||||
enabled: true
|
||||
"@ | Set-Content (Join-Path $CfgDir "application-inquiry.yml") -Encoding UTF8
|
||||
}
|
||||
if (-not (Test-Path $Jar)) { throw "jar missing: $Jar" }
|
||||
$ji = Get-Item $Jar
|
||||
Write-Output "jar bytes=$($ji.Length) mtime=$($ji.LastWriteTime)"
|
||||
|
||||
# 加载 E:\wwwroot\.env
|
||||
Get-Content 'E:\wwwroot\.env' | ForEach-Object {
|
||||
Get-Content "E:\wwwroot\.env" | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith('#')) { return }
|
||||
$i = $line.IndexOf('=')
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
$i = $line.IndexOf("=")
|
||||
if ($i -lt 1) { return }
|
||||
$k = $line.Substring(0, $i).Trim()
|
||||
$v = $line.Substring($i + 1).Trim()
|
||||
if ($v.StartsWith('"') -and $v.EndsWith('"')) { $v = $v.Substring(1, $v.Length - 2) }
|
||||
[Environment]::SetEnvironmentVariable($k, $v, 'Process')
|
||||
Set-Item -Path "Env:$k" -Value $v
|
||||
$v = $line.Substring($i + 1).Trim().Trim('"')
|
||||
Set-Item -Path ("Env:" + $k) -Value $v
|
||||
}
|
||||
|
||||
if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) {
|
||||
if ($env:REDIS_URL -match 'redis://:([^@]+)@') {
|
||||
$env:YTD_REDIS_PASSWORD = $Matches[1]
|
||||
} elseif ($env:REDIS_URL -match 'redis://[^:]+:([^@]+)@') {
|
||||
$env:YTD_REDIS_PASSWORD = $Matches[1]
|
||||
}
|
||||
if ($env:REDIS_URL -match "redis://:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] }
|
||||
elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] }
|
||||
}
|
||||
if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = '127.0.0.1' }
|
||||
$env:YTD_REDIS_HOST = '127.0.0.1'
|
||||
$env:LEDGER_PORT = '8180'
|
||||
if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = "10.206.0.14" }
|
||||
if (-not $env:YTD_REDIS_PORT) { $env:YTD_REDIS_PORT = "6379" }
|
||||
if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" }
|
||||
$env:LEDGER_PORT = "8180"
|
||||
Write-Host ("MYSQL_DB=" + $env:MYSQL_DB + " MYSQL_HOST=" + $env:MYSQL_HOST + " REDIS_HOST=" + $env:YTD_REDIS_HOST + " REDIS_DB=" + $env:YTD_REDIS_DB)
|
||||
|
||||
Write-Host "MYSQL_DB=$env:MYSQL_DB MYSQL_HOST=$env:MYSQL_HOST LEDGER_PORT=$env:LEDGER_PORT"
|
||||
|
||||
# 只停 inquiry-new 的 8180,绝不碰 yutongda 8080
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') {
|
||||
Write-Host "stop pid=$($_.ProcessId)"
|
||||
if ($_.CommandLine -and $_.CommandLine -like "*inquiry-new*jeecg-system-start*") {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Get-NetTCPConnection -LocalPort 8180 -ErrorAction SilentlyContinue |
|
||||
Select-Object -ExpandProperty OwningProcess -Unique |
|
||||
ForEach-Object {
|
||||
$p = Get-CimInstance Win32_Process -Filter "ProcessId=$_"
|
||||
if ($p -and $p.CommandLine -like '*inquiry-new*') {
|
||||
Stop-Process -Id $_ -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$Java = 'C:\yutongda\tools\jdk8\bin\java.exe'
|
||||
if (-not (Test-Path $Java)) { throw "java missing $Java" }
|
||||
$Out = Join-Path $LogDir 'stdout.log'
|
||||
$Err = Join-Path $LogDir 'stderr.log'
|
||||
$Java = "C:\yutongda\tools\jdk8\bin\java.exe"
|
||||
$Out = Join-Path $LogDir "stdout.log"
|
||||
$Err = Join-Path $LogDir "stderr.log"
|
||||
# truncate logs
|
||||
Set-Content $Out -Value "" -Encoding UTF8
|
||||
Set-Content $Err -Value "" -Encoding UTF8
|
||||
$arg = @(
|
||||
'-jar', $Jar,
|
||||
'--spring.profiles.active=inquiry',
|
||||
"--server.port=$env:LEDGER_PORT"
|
||||
"-jar", $Jar,
|
||||
"--spring.profiles.active=inquiry",
|
||||
"--server.port=8180",
|
||||
"--spring.config.additional-location=optional:file:./config/",
|
||||
"--cas.prefixUrl=http://127.0.0.1/cas-disabled"
|
||||
)
|
||||
Write-Host "starting $Java $($arg -join ' ')"
|
||||
Write-Host ("starting java jar inquiry profile redis=" + $env:YTD_REDIS_HOST + ":" + $env:YTD_REDIS_PORT + "/" + $env:YTD_REDIS_DB)
|
||||
$p = Start-Process -FilePath $Java -ArgumentList $arg -WorkingDirectory $Root -RedirectStandardOutput $Out -RedirectStandardError $Err -PassThru -WindowStyle Hidden
|
||||
Write-Host "started pid=$($p.Id)"
|
||||
Write-Host ("started pid=" + $p.Id)
|
||||
|
||||
# 等启动(最长约 90s)
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 18; $i++) {
|
||||
for ($i = 1; $i -le 24; $i++) {
|
||||
Start-Sleep -Seconds 5
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/' -UseBasicParsing -TimeoutSec 5
|
||||
Write-Host "health try=$i status=$($r.StatusCode)"
|
||||
$r = Invoke-WebRequest -Uri "http://127.0.0.1:8180/jeecgboot/" -UseBasicParsing -TimeoutSec 5
|
||||
Write-Host ("health try=" + $i + " status=" + $r.StatusCode)
|
||||
$ok = $true
|
||||
break
|
||||
} catch {
|
||||
Write-Host "health try=$i pending"
|
||||
Write-Host ("health try=" + $i + " pending")
|
||||
}
|
||||
}
|
||||
if (-not $ok) {
|
||||
Write-Host '--- stderr tail ---'
|
||||
if (Test-Path $Err) { Get-Content $Err -Tail 50 }
|
||||
throw 'ledger 8180 health failed'
|
||||
if (Test-Path $Err) { Get-Content $Err -Tail 60 }
|
||||
throw "ledger 8180 health failed"
|
||||
}
|
||||
|
||||
# 确认现网 8080 仍在
|
||||
$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $prod) { throw 'CRITICAL: prod 8080 down after start' }
|
||||
Write-Output 'LEDGER_8180_OK'
|
||||
Write-Output "PROD_8080_OK pid=$($prod.OwningProcess)"
|
||||
if (-not $prod) { throw "CRITICAL: prod 8080 down" }
|
||||
Write-Output "LEDGER_8180_OK"
|
||||
Write-Output ("PROD_8080_OK pid=" + $prod.OwningProcess)
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Copy-Item 'E:\wwwroot\.env' 'E:\wwwroot\agent\current\.env' -Force -ErrorAction SilentlyContinue
|
||||
Get-ChildItem 'E:\wwwroot\agent\releases' -ErrorAction SilentlyContinue | Where-Object { $_.PSIsContainer } | ForEach-Object {
|
||||
$p1 = Join-Path $_.FullName 'agent\.env'
|
||||
$p2 = Join-Path $_.FullName '.env'
|
||||
if (Test-Path (Split-Path $p1)) { Copy-Item 'E:\wwwroot\.env' $p1 -Force -ErrorAction SilentlyContinue }
|
||||
Copy-Item 'E:\wwwroot\.env' $p2 -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
Write-Output 'SYNC_OK'
|
||||
@@ -0,0 +1,16 @@
|
||||
# 只读:公网后台到底是哪套静态
|
||||
$ErrorActionPreference = 'Continue'
|
||||
Write-Output '=== admin current ==='
|
||||
cmd /c 'dir E:\wwwroot\admin\current'
|
||||
if (Test-Path 'E:\wwwroot\ops\active-release.json') {
|
||||
Get-Content 'E:\wwwroot\ops\active-release.json' -Raw
|
||||
}
|
||||
Write-Output '=== index title sniff ==='
|
||||
$idx = 'E:\wwwroot\admin\current\index.html'
|
||||
if (Test-Path $idx) {
|
||||
Select-String -Path $idx -Pattern 'title|询价|jeecg|vite' | Select-Object -First 8 | ForEach-Object { $_.Line.Trim() }
|
||||
}
|
||||
Write-Output '=== nginx jeecgboot / root ==='
|
||||
Select-String -Path 'C:\nginx-1.18.0\myconf\ai.conf' -Pattern 'root |jeecgboot|proxy_pass|8180|8080' |
|
||||
ForEach-Object { $_.Line.Trim() }
|
||||
Write-Output 'ADMIN_WHY_DONE'
|
||||
@@ -0,0 +1,108 @@
|
||||
# 持久拉起主账:由 WMI 启动 cmd 循环,SSH 断开后仍存活。禁止动现网 8080。
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$Root = 'E:\wwwroot\inquiry-new'
|
||||
$Www = 'E:\wwwroot'
|
||||
$Jar = Join-Path $Root 'jeecg-system-start-3.5.3.jar'
|
||||
$LogDir = Join-Path $Root 'logs'
|
||||
$CfgDir = Join-Path $Root 'config'
|
||||
$IncomingJar = 'C:\Users\ytd_extdev_0903\incoming\jeecg-system-start-3.5.3.jar'
|
||||
$IncomingCfg = 'C:\Users\ytd_extdev_0903\incoming\ledger-config.zip'
|
||||
$Java = 'C:\yutongda\tools\jdk8\bin\java.exe'
|
||||
New-Item -ItemType Directory -Force -Path $Root, $LogDir, $CfgDir, (Join-Path $Www 'ops') | Out-Null
|
||||
|
||||
if (Test-Path $IncomingJar) { Copy-Item $IncomingJar $Jar -Force }
|
||||
if (-not (Test-Path $Jar)) { throw 'jar missing' }
|
||||
if (-not (Test-Path $Java)) { throw 'java missing' }
|
||||
if (Test-Path $IncomingCfg) {
|
||||
Expand-Archive -Path $IncomingCfg -DestinationPath $CfgDir -Force
|
||||
}
|
||||
|
||||
$inner = @'
|
||||
$ErrorActionPreference = "Stop"
|
||||
Get-Content "E:\wwwroot\.env" | ForEach-Object {
|
||||
$line = $_.Trim()
|
||||
if (-not $line -or $line.StartsWith("#")) { return }
|
||||
$i = $line.IndexOf("=")
|
||||
if ($i -lt 1) { return }
|
||||
Set-Item -Path ("Env:" + $line.Substring(0, $i).Trim()) -Value $line.Substring($i + 1).Trim().Trim('"')
|
||||
}
|
||||
if (-not $env:YTD_REDIS_PASSWORD -and $env:REDIS_URL) {
|
||||
if ($env:REDIS_URL -match "redis://:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] }
|
||||
elseif ($env:REDIS_URL -match "redis://[^:]+:([^@]+)@") { $env:YTD_REDIS_PASSWORD = $Matches[1] }
|
||||
}
|
||||
if (-not $env:YTD_REDIS_HOST) { $env:YTD_REDIS_HOST = "10.206.0.14" }
|
||||
if (-not $env:YTD_REDIS_PORT) { $env:YTD_REDIS_PORT = "6379" }
|
||||
if (-not $env:YTD_REDIS_DB) { $env:YTD_REDIS_DB = "1" }
|
||||
$env:LEDGER_PORT = "8180"
|
||||
$Java = "C:\yutongda\tools\jdk8\bin\java.exe"
|
||||
$Jar = "E:\wwwroot\inquiry-new\jeecg-system-start-3.5.3.jar"
|
||||
Set-Location "E:\wwwroot\inquiry-new"
|
||||
$arg = @(
|
||||
"-jar", $Jar,
|
||||
"--spring.profiles.active=inquiry",
|
||||
"--server.port=8180",
|
||||
"--spring.config.additional-location=optional:file:./config/",
|
||||
"--cas.prefixUrl=http://127.0.0.1/cas-disabled",
|
||||
"--jeecg.loginCaptchaEnabled=false"
|
||||
)
|
||||
if ($env:YTD_REDIS_USERNAME) { $arg += ("--spring.redis.username=" + $env:YTD_REDIS_USERNAME) }
|
||||
& $Java @arg
|
||||
'@
|
||||
$innerPath = Join-Path $Www 'ops\run-ledger-inner.ps1'
|
||||
Set-Content -Path $innerPath -Value $inner -Encoding UTF8
|
||||
|
||||
$cmd = @"
|
||||
@echo off
|
||||
cd /d E:\wwwroot\inquiry-new
|
||||
:loop
|
||||
powershell.exe -NoProfile -ExecutionPolicy Bypass -File E:\wwwroot\ops\run-ledger-inner.ps1 >> E:\wwwroot\inquiry-new\logs\stdout.log 2>> E:\wwwroot\inquiry-new\logs\stderr.log
|
||||
echo ledger exited, restart in 5s >> E:\wwwroot\inquiry-new\logs\stderr.log
|
||||
timeout /t 5 /nobreak >nul
|
||||
goto loop
|
||||
"@
|
||||
$cmdPath = Join-Path $Www 'ops\run-ledger.cmd'
|
||||
Set-Content -Path $cmdPath -Value $cmd -Encoding ASCII
|
||||
|
||||
# 只停 inquiry-new 的 java
|
||||
Get-CimInstance Win32_Process -Filter "Name='java.exe'" | ForEach-Object {
|
||||
if ($_.CommandLine -and $_.CommandLine -like '*inquiry-new*jeecg-system-start*') {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Get-CimInstance Win32_Process | ForEach-Object {
|
||||
if ($_.CommandLine -and $_.CommandLine -like '*run-ledger.cmd*') {
|
||||
Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
Start-Sleep -Seconds 2
|
||||
|
||||
$w = Invoke-CimMethod -ClassName Win32_Process -MethodName Create -Arguments @{
|
||||
CommandLine = ('cmd.exe /c "' + $cmdPath + '"')
|
||||
}
|
||||
Write-Output ("LEDGER_WMI=" + $w.ReturnValue + " pid=" + $w.ProcessId)
|
||||
|
||||
$ok = $false
|
||||
for ($i = 1; $i -le 30; $i++) {
|
||||
Start-Sleep -Seconds 5
|
||||
$listen = Get-NetTCPConnection -LocalPort 8180 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $listen) { Write-Output ("try=" + $i + " not listen"); continue }
|
||||
try {
|
||||
$r = Invoke-WebRequest -Uri 'http://127.0.0.1:8180/jeecgboot/sys/randomImage/wmi' -UseBasicParsing -TimeoutSec 5
|
||||
Write-Output ("try=" + $i + " listen pid=" + $listen.OwningProcess + " http=" + $r.StatusCode)
|
||||
$ok = $true
|
||||
break
|
||||
} catch {
|
||||
$code = $null
|
||||
try { $code = $_.Exception.Response.StatusCode.value__ } catch {}
|
||||
Write-Output ("try=" + $i + " listen pid=" + $listen.OwningProcess + " http_code=" + $code)
|
||||
if ($code) { $ok = $true; break }
|
||||
}
|
||||
}
|
||||
if (-not $ok) {
|
||||
Get-Content (Join-Path $LogDir 'stdout.log') -Tail 40 -ErrorAction SilentlyContinue
|
||||
throw 'ledger 8180 failed to stay up'
|
||||
}
|
||||
$prod = Get-NetTCPConnection -LocalPort 8080 -State Listen -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if (-not $prod) { throw 'CRITICAL: prod 8080 down' }
|
||||
Write-Output 'LEDGER_WMI_OK'
|
||||
Write-Output ("PROD_8080_OK pid=" + $prod.OwningProcess)
|
||||
@@ -0,0 +1,74 @@
|
||||
# ai.ytd-scm.com cutover: Jeecg admin static + ledger 8180 + agent 8910
|
||||
# /jeecgboot -> 新主账 8180(context-path /jeecgboot)
|
||||
# /jeecg-boot -> 仍指现网 8080(旧路径兼容,正式前端不用)
|
||||
# wecom / inquiry-form -> 8910
|
||||
server {
|
||||
listen 80;
|
||||
server_name ai.ytd-scm.com;
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name ai.ytd-scm.com;
|
||||
ssl_certificate C:/nginx-1.18.0/cert/ai.ytd-scm.com.crt;
|
||||
ssl_certificate_key C:/nginx-1.18.0/cert/ai.ytd-scm.com.key;
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
root E:/wwwroot/admin/current;
|
||||
index index.html;
|
||||
location = / { add_header Cache-Control "no-store" always; try_files /index.html =404; }
|
||||
location = /index.html { add_header Cache-Control "no-store" always; }
|
||||
location / { try_files $uri $uri/ /index.html; }
|
||||
location /jeecg-boot/ {
|
||||
proxy_pass http://127.0.0.1:8080/jeecg-boot/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
location /jeecgboot/ {
|
||||
proxy_pass http://127.0.0.1:8180/jeecgboot/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
proxy_read_timeout 120s;
|
||||
}
|
||||
location = /wecom/callback {
|
||||
proxy_pass http://127.0.0.1:8910/wecom/callback;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 20m;
|
||||
}
|
||||
location = /wecom/robot/callback {
|
||||
proxy_pass http://127.0.0.1:8812/wecom/robot/callback;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
client_max_body_size 100m;
|
||||
}
|
||||
location ^~ /inquiry-form/upload/ {
|
||||
proxy_pass http://127.0.0.1:8910/inquiry-form/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
add_header Cache-Control "no-store" always;
|
||||
client_max_body_size 21m;
|
||||
}
|
||||
location ^~ /inquiry-form/ {
|
||||
proxy_pass http://127.0.0.1:8910/inquiry-form/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto https;
|
||||
add_header Cache-Control "no-store" always;
|
||||
client_max_body_size 256k;
|
||||
}
|
||||
location = /wecom/callback/health { proxy_pass http://127.0.0.1:8910/health; }
|
||||
location = /agent/health { proxy_pass http://127.0.0.1:8910/health; }
|
||||
location = /ledger/health { proxy_pass http://127.0.0.1:8180/jeecgboot/; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# 覆盖配置:明确关闭登录图形验证码(与无验证码登录页对齐)
|
||||
cas:
|
||||
prefixUrl: http://127.0.0.1/cas-disabled
|
||||
|
||||
spring:
|
||||
redis:
|
||||
host: ${YTD_REDIS_HOST:10.206.0.14}
|
||||
port: ${YTD_REDIS_PORT:6379}
|
||||
database: ${YTD_REDIS_DB:1}
|
||||
username: ${YTD_REDIS_USERNAME:}
|
||||
password: ${YTD_REDIS_PASSWORD:}
|
||||
|
||||
jeecg:
|
||||
loginCaptchaEnabled: false
|
||||
swagger:
|
||||
title: 友通达询价系统 API
|
||||
description: 询价主账与后台关联接口
|
||||
base-packages: org.jeecg.modules.inquiry
|
||||
extra-controller-names: LoginController,SysUserController,SysRoleController,SysPermissionController,SysDictController,SysDictItemController,CommonController
|
||||
file-view-domain: http://127.0.0.1
|
||||
elasticsearch:
|
||||
cluster-name: jeecg-ES
|
||||
cluster-nodes: 127.0.0.1:9200
|
||||
check-enabled: false
|
||||
redisson:
|
||||
enabled: false
|
||||
xxljob:
|
||||
enabled: false
|
||||
|
||||
knife4j:
|
||||
enable: true
|
||||
production: false
|
||||
|
||||
justauth:
|
||||
enabled: true
|
||||
type:
|
||||
GITHUB:
|
||||
client-id: local
|
||||
client-secret: local
|
||||
redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/github/callback
|
||||
WECHAT_ENTERPRISE:
|
||||
client-id: local
|
||||
client-secret: local
|
||||
redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/wechat_enterprise/callback
|
||||
agent-id: "0"
|
||||
DINGTALK:
|
||||
client-id: local
|
||||
client-secret: local
|
||||
redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/dingtalk/callback
|
||||
WECHAT_OPEN:
|
||||
client-id: local
|
||||
client-secret: local
|
||||
redirect-uri: http://127.0.0.1:8180/jeecgboot/sys/thirdLogin/wechat_open/callback
|
||||
cache:
|
||||
type: default
|
||||
prefix: 'demo::'
|
||||
timeout: 1h
|
||||
|
||||
third-app:
|
||||
enabled: false
|
||||
@@ -0,0 +1,24 @@
|
||||
# NSSM / Windows 服务单元清单(框架壳)
|
||||
|
||||
新系统只注册下列自研服务(另加 Nginx/LibreOffice/库,不计入本表)。
|
||||
**禁止**五个 Worker、四个 uvicorn、Redis `wecom:worker:singleton`。
|
||||
|
||||
并存期端口:主账 8180、智能体 HTTP 8910、AIBOT 8913;勿占现网 8080/8810/8813。
|
||||
|
||||
| 服务名(建议) | 进程 | 工作目录(示例) | 启动命令(示例) | 备注 |
|
||||
|---|---|---|---|---|
|
||||
| `YtdInquiryLedger` | 主账 Jeecg | `E:\wwwroot\inquiry-new` | `java -jar …`(以发布脚本为准) | 端口 8180;库 `inquiry_robot` |
|
||||
| `YtdInquiryAgentHttp` | 智能体 HTTP | `E:\wwwroot\agent\current` | `python -m agent.http_app` | 端口 8910;只验签入队 |
|
||||
| `YtdInquiryAgentWorker` | 智能体 Worker | 同上 | `python -m agent.worker_app` | 无对外端口;LibreOffice 1 槽 |
|
||||
| `YtdInquiryAibotBridge` | AIBOT 桥 | `E:\wwwroot\aibot-bridge` | `node server.cjs` | 绑定 127.0.0.1:8913;默认不连 Bot |
|
||||
| `YtdInquiryAdmin` | 可选静态托管 | `E:\wwwroot\admin\current` | 通常由 Nginx 托管,可不注册 NSSM | 经 `/jeecgboot` 打 8180 |
|
||||
|
||||
## 环境
|
||||
|
||||
- 活动配置真相:`E:\wwwroot\.env`(勿改 `C:\yutongda` 活动 `.env`)
|
||||
- `YTD_ENV=test|prod`;测试告警正文加 `[test]`
|
||||
- AIBOT:`WECOM_AIBOT_ENABLED=false` 直至切流
|
||||
|
||||
## 本文件范围
|
||||
|
||||
只列服务名与进程边界;不含切流 Nginx、不含业务开关。具体安装命令见 `deploy/` 下远程脚本。
|
||||
+39
-2
@@ -1,8 +1,45 @@
|
||||
# 智能体 agent
|
||||
|
||||
Python 3.12 + FastAPI。HTTP 默认 `8910`。
|
||||
Python 3.12 + FastAPI。HTTP 默认 `8910`;Worker 另起进程。
|
||||
|
||||
## 基础框架自检
|
||||
|
||||
```text
|
||||
set REDIS_BACKEND=memory
|
||||
set CHECKPOINT_BACKEND=memory
|
||||
python -m agent.foundation_selftest
|
||||
python -m agent.runtime_db.migrate --list
|
||||
```
|
||||
|
||||
PG 真迁移:配好 `PG_*` 后 `python -m agent.runtime_db.migrate`(禁止 `ytd_runtime`)。
|
||||
|
||||
消息账本:`MESSAGE_STORE_BACKEND=auto|memory|postgres`(有 DSN 时优先 PG inbox/outbox)。
|
||||
|
||||
## Redis 骨架
|
||||
|
||||
- 包:`agent/redis_coord/`(连接+前缀、Stream 入队/认领、短幂等、限流、token 缓存)。
|
||||
- **db1 不是名字**:Redis 只有数字逻辑库(URL `…/1`),与现网 `…/0` 隔离;可读名是键前缀 **`inquiry_robot:`**。
|
||||
- 自检:`set REDIS_BACKEND=memory` 后 `python -m agent.redis_coord.selftest`。
|
||||
- 正式:`REDIS_URL=redis://…@host:6379/1` + `REDIS_KEY_PREFIX=inquiry_robot:`(禁止 `ytd:prod:`)。
|
||||
|
||||
## LangGraph 骨架
|
||||
|
||||
- 包:`agent/graph/`(状态、分文件空节点、interrupt 停点、PG/Memory checkpoint、start/resume/wake)。
|
||||
- **无询价主逻辑**;节点只打日志。HTTP 回调禁止 `invoke` 整图。
|
||||
- 自检:`set CHECKPOINT_BACKEND=memory` 后执行 `python -m agent.graph.selftest`。
|
||||
- 正式:`CHECKPOINT_BACKEND=postgres` + `PG_*` 指向 `inquiry_robot_runtime`(禁止 `ytd_runtime`)。
|
||||
|
||||
## 本迭代骨架(路由 / PDF / 识别)
|
||||
|
||||
- DeepSeek A:`agent/routing/ordered_actions.py` + `deepseek_a.py`(strict `route_decision` tool;默认 stub,不开费)。
|
||||
- 分发:`agent/routing/dispatch.py`;通道 inbox → route → handler 表。
|
||||
- 真网开关:`LLM_DATA_USAGE_CONFIRMED=true` 且 `LLM_ALLOW_NETWORK=true`(Worker)才可能打模型。
|
||||
- LibreOffice:`agent/jobs/libreoffice.py` + `stream:pdf`(仅 1 槽);路径 `LIBREOFFICE_PATH`。
|
||||
- 识别 / 存档:`stream:recognition` / `stream:archive` 入队壳。
|
||||
- 字段合同:仓库 `contracts/*.json`;拉取备份在 `_ref/prod-pull/`(gitignore)。
|
||||
- 本机密钥:仓库根 `.env`(gitignore);新库口令来自服务器 `E:\wwwroot\ops\inquiry_robot_db_secrets.json`。
|
||||
- 另:session/suspend/job/死信、LLM 各 mode 空模块、S3 壳、ledger API 签名壳、模板 registry、policy、AIBOT 长连接壳、Java TMS 空包、`deploy/nssm/SERVICES.md`。
|
||||
|
||||
## 本迭代:企微文字 echo 回复
|
||||
|
||||
链路:回调/注入 → inbox 入队(快回)→ echo handler → outbox → 企微 `message/send`。
|
||||
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
AIBOT 入站入口:桥进程把长连接消息转到本路由。
|
||||
|
||||
本文件职责:校验 bridge token、归一 InboundMessage、入 inbox 快回。
|
||||
禁止:在本请求线程调 LLM / Graph / 出站企微。
|
||||
路径:/internal/aibot/inbound(对齐 prompt/13)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.config import get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/aibot", tags=["aibot"])
|
||||
|
||||
|
||||
class AibotInboundBody(BaseModel):
|
||||
"""桥转发的归一字段(桥侧已解析,智能体不再调企微换 ID)。"""
|
||||
|
||||
sender_id: str = Field(description="企微 userid,必须来自 AIBOT 报文")
|
||||
message_id: str
|
||||
content: str = ""
|
||||
msg_type: str = "text"
|
||||
chat_id: str = ""
|
||||
chat_type: str = "group"
|
||||
agent_id: str = ""
|
||||
create_time: int = 0
|
||||
media: dict[str, Any] = Field(default_factory=dict)
|
||||
raw: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _check_bridge_token(x_token: Optional[str]) -> None:
|
||||
s = get_settings()
|
||||
expected = (getattr(s, "wecom_aibot_bridge_token", None) or "").strip()
|
||||
if not expected:
|
||||
if (s.ytd_env or "").lower() == "prod":
|
||||
raise HTTPException(status_code=503, detail="AIBOT bridge token 未配置")
|
||||
logger.warning("aibot inbound:未配置 bridge token,test 跳过校验")
|
||||
return
|
||||
if (x_token or "").strip() != expected:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def aibot_ping() -> dict[str, str]:
|
||||
return {"status": "ok", "channel": "aibot"}
|
||||
|
||||
|
||||
@router.post("/inbound")
|
||||
def aibot_inbound(
|
||||
body: AibotInboundBody,
|
||||
x_aibot_bridge_token: Optional[str] = Header(
|
||||
default=None, alias="X-Aibot-Bridge-Token"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
接收桥转发入站:无 sender_id 拒绝;其余入 inbox。
|
||||
"""
|
||||
_check_bridge_token(x_aibot_bridge_token)
|
||||
msg = InboundMessage(
|
||||
sender_id=body.sender_id.strip(),
|
||||
message_id=body.message_id.strip(),
|
||||
content=body.content or "",
|
||||
msg_type=body.msg_type or "text",
|
||||
chat_id=body.chat_id or "",
|
||||
chat_type=body.chat_type or "group",
|
||||
agent_id=body.agent_id or "",
|
||||
create_time=int(body.create_time or 0),
|
||||
media=body.media or {},
|
||||
raw=body.raw or {},
|
||||
)
|
||||
if not msg.has_sender():
|
||||
raise HTTPException(status_code=400, detail="missing_sender_id")
|
||||
if not msg.message_id:
|
||||
raise HTTPException(status_code=400, detail="missing_message_id")
|
||||
|
||||
ok, info = get_message_store().enqueue_inbound(msg)
|
||||
logger.info(
|
||||
"aibot.inbound ok=%s info=%s sender=%s msg_id=%s",
|
||||
ok,
|
||||
info,
|
||||
msg.sender_id,
|
||||
msg.message_id,
|
||||
)
|
||||
return {"status": "accepted" if ok else "duplicate", "detail": info}
|
||||
@@ -1,18 +1,102 @@
|
||||
"""
|
||||
主账唤醒桥接路由(合并原 bridge_api)。
|
||||
|
||||
本文件职责:接收主账鉴权后的 wake 事件,入队由 Worker 结构化 resume。
|
||||
本文件职责:校验 callback token,接收结构化 wake,写入 Redis Stream 后快回。
|
||||
禁止:在本请求线程跑完整 Graph;禁止本包直连 MySQL / TMS。
|
||||
Worker:jobs.wake_consumer 认领后 GraphRuntime.wake_from_worker。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.jobs.wake_consumer import enqueue_wake
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/internal/bridge", tags=["bridge"])
|
||||
|
||||
|
||||
class BridgeWakeBody(BaseModel):
|
||||
"""主账 → 智能体结构化唤醒。"""
|
||||
|
||||
thread_id: str
|
||||
inquiry_no: str
|
||||
quote_version: int = 0
|
||||
wait_version: int = 0
|
||||
kind: str = Field(description="如 recognition_done / pdf_done / ledger_transition")
|
||||
detail: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
def _expected_token() -> str:
|
||||
s = get_settings()
|
||||
return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip()
|
||||
|
||||
|
||||
def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None:
|
||||
expected = _expected_token()
|
||||
if not expected:
|
||||
if (get_settings().ytd_env or "").lower() == "prod":
|
||||
raise HTTPException(status_code=503, detail="callback token 未配置")
|
||||
logger.warning("bridge:未配置 callback token,test 允许空校验(仅骨架)")
|
||||
return
|
||||
provided = (x_agent_token or "").strip()
|
||||
if not provided and authorization:
|
||||
auth = authorization.strip()
|
||||
if auth.lower().startswith("bearer "):
|
||||
provided = auth[7:].strip()
|
||||
else:
|
||||
provided = auth
|
||||
if provided != expected:
|
||||
raise HTTPException(status_code=401, detail="unauthorized")
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def bridge_ping() -> dict[str, str]:
|
||||
"""主账唤醒路径占位;正式实现需校验 AGENT_CALLBACK_TOKEN。"""
|
||||
return {"status": "ok", "channel": "bridge", "note": "scaffold-only"}
|
||||
return {"status": "ok", "channel": "bridge"}
|
||||
|
||||
|
||||
@router.post("/wake")
|
||||
def bridge_wake(
|
||||
body: BridgeWakeBody,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
x_agent_callback_token: Optional[str] = Header(
|
||||
default=None, alias="X-Agent-Callback-Token"
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
接收结构化 wake:校验后写入 Redis Stream,立即返回。
|
||||
|
||||
不在此 invoke Graph。
|
||||
"""
|
||||
_check_token(authorization, x_agent_callback_token)
|
||||
try:
|
||||
payload = WorkerWakePayload(
|
||||
thread_id=body.thread_id,
|
||||
inquiry_no=body.inquiry_no,
|
||||
quote_version=body.quote_version,
|
||||
wait_version=body.wait_version,
|
||||
kind=body.kind,
|
||||
detail=body.detail,
|
||||
)
|
||||
entry_id = enqueue_wake(get_redis_runtime().client, payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("bridge.wake 入队失败")
|
||||
raise HTTPException(status_code=503, detail=f"enqueue_failed:{exc}") from exc
|
||||
|
||||
logger.info(
|
||||
"bridge.wake 已入队 Redis thread=%s kind=%s id=%s",
|
||||
body.thread_id,
|
||||
body.kind,
|
||||
entry_id,
|
||||
)
|
||||
return {"status": "accepted", "entry_id": entry_id}
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
"""
|
||||
clarification / H5 token 绑定(骨架)。
|
||||
|
||||
本文件职责:签发与校验一次性 token;优先写 PG clarification_binding,无库则内存。
|
||||
禁止:URL 携带 userid;身份以绑定时的 sender_id 为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# H5 TTL 30 分钟(prompt/14)
|
||||
DEFAULT_TTL_SECONDS = 30 * 60
|
||||
|
||||
|
||||
@dataclass
|
||||
class ClarificationRecord:
|
||||
token: str
|
||||
thread_id: str
|
||||
wait_version: int
|
||||
sender_id: str
|
||||
allowed_actions: list[str]
|
||||
expires_at: float
|
||||
consumed: bool = False
|
||||
|
||||
|
||||
_MEM: dict[str, ClarificationRecord] = {}
|
||||
_MEM_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def issue_token(
|
||||
*,
|
||||
thread_id: str,
|
||||
wait_version: int,
|
||||
sender_id: str,
|
||||
allowed_actions: list[str],
|
||||
ttl_seconds: int = DEFAULT_TTL_SECONDS,
|
||||
) -> str:
|
||||
"""
|
||||
签发 clarification token。
|
||||
|
||||
副作用:写 PG 或内存;返回随机 token(不进 URL 的 userid)。
|
||||
"""
|
||||
token = secrets.token_urlsafe(24)
|
||||
expires = time.time() + ttl_seconds
|
||||
rec = ClarificationRecord(
|
||||
token=token,
|
||||
thread_id=thread_id,
|
||||
wait_version=wait_version,
|
||||
sender_id=sender_id,
|
||||
allowed_actions=list(allowed_actions),
|
||||
expires_at=expires,
|
||||
)
|
||||
if _try_pg_insert(rec, ttl_seconds):
|
||||
return token
|
||||
with _MEM_LOCK:
|
||||
_MEM[token] = rec
|
||||
logger.info("clarification 内存签发 thread=%s wait=%s", thread_id, wait_version)
|
||||
return token
|
||||
|
||||
|
||||
def validate_token(
|
||||
token: str,
|
||||
*,
|
||||
action: str = "",
|
||||
consume: bool = False,
|
||||
) -> Optional[ClarificationRecord]:
|
||||
"""
|
||||
校验 token:存在、未过期、未消费;若指定 action 须在允许集合。
|
||||
|
||||
consume=True:一次性消费。
|
||||
"""
|
||||
if not (token or "").strip():
|
||||
return None
|
||||
rec = _try_pg_load(token) or _mem_load(token)
|
||||
if rec is None:
|
||||
return None
|
||||
if rec.consumed or rec.expires_at < time.time():
|
||||
return None
|
||||
if action and action not in rec.allowed_actions:
|
||||
return None
|
||||
if consume:
|
||||
_consume(token)
|
||||
rec.consumed = True
|
||||
return rec
|
||||
|
||||
|
||||
def _mem_load(token: str) -> Optional[ClarificationRecord]:
|
||||
with _MEM_LOCK:
|
||||
return _MEM.get(token)
|
||||
|
||||
|
||||
def _consume(token: str) -> None:
|
||||
if _try_pg_consume(token):
|
||||
return
|
||||
with _MEM_LOCK:
|
||||
rec = _MEM.get(token)
|
||||
if rec:
|
||||
rec.consumed = True
|
||||
|
||||
|
||||
def _try_pg_insert(rec: ClarificationRecord, ttl_seconds: int) -> bool:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return False
|
||||
try:
|
||||
assert_safe_database(dsn)
|
||||
expires = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
import json
|
||||
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO clarification_binding (
|
||||
token, thread_id, wait_version, sender_id, allowed_actions, expires_at
|
||||
) VALUES (%s, %s, %s, %s, %s::jsonb, %s)
|
||||
""",
|
||||
(
|
||||
rec.token,
|
||||
rec.thread_id,
|
||||
rec.wait_version,
|
||||
rec.sender_id,
|
||||
json.dumps(rec.allowed_actions, ensure_ascii=False),
|
||||
expires,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("clarification PG 写入失败,改用内存:%s", exc)
|
||||
return False
|
||||
|
||||
|
||||
def _try_pg_load(token: str) -> Optional[ClarificationRecord]:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return None
|
||||
try:
|
||||
with connect_psycopg(dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT thread_id, wait_version, sender_id, allowed_actions,
|
||||
EXTRACT(EPOCH FROM expires_at), consumed_at
|
||||
FROM clarification_binding WHERE token = %s
|
||||
""",
|
||||
(token,),
|
||||
).fetchone()
|
||||
if not row:
|
||||
return None
|
||||
actions = row[3]
|
||||
if isinstance(actions, str):
|
||||
import json
|
||||
|
||||
actions = json.loads(actions)
|
||||
return ClarificationRecord(
|
||||
token=token,
|
||||
thread_id=str(row[0]),
|
||||
wait_version=int(row[1]),
|
||||
sender_id=str(row[2]),
|
||||
allowed_actions=list(actions or []),
|
||||
expires_at=float(row[4] or 0),
|
||||
consumed=row[5] is not None,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
return None
|
||||
|
||||
|
||||
def _try_pg_consume(token: str) -> bool:
|
||||
settings = get_settings()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
return False
|
||||
try:
|
||||
with connect_psycopg(dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE clarification_binding
|
||||
SET consumed_at = NOW()
|
||||
WHERE token = %s AND consumed_at IS NULL
|
||||
""",
|
||||
(token,),
|
||||
)
|
||||
conn.commit()
|
||||
return True
|
||||
except Exception: # noqa: BLE001
|
||||
return False
|
||||
@@ -1,22 +1,79 @@
|
||||
"""
|
||||
H5 补问 / 附件上传通道。
|
||||
|
||||
本文件职责:挂载 /inquiry-form 相关壳路由;字段快照与流程状态在 Graph/PG,不在本包。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS。
|
||||
本文件职责:挂载 /inquiry-form;用 clarification token 绑定身份(URL 禁止 userid)。
|
||||
禁止:从 URL 读 userid;禁止在本包改六态或直连 TMS;禁止同步 invoke Graph。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from agent.channel.clarification import validate_token
|
||||
from agent.handlers.card_action import handle_card_action
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/inquiry-form", tags=["h5"])
|
||||
|
||||
|
||||
class H5SubmitBody(BaseModel):
|
||||
"""H5 提交体:业务字段放 fields;身份只认服务端 token 绑定。"""
|
||||
|
||||
action: str = Field(default="submit_clarify")
|
||||
fields: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
@router.get("/ping")
|
||||
def h5_ping() -> dict[str, str]:
|
||||
"""
|
||||
H5 路由占位探测。
|
||||
"""存活探测。"""
|
||||
return {"status": "ok", "channel": "h5"}
|
||||
|
||||
正式页用一次性 token;骨架不校验 token、不落库。
|
||||
|
||||
@router.get("/{token}")
|
||||
def h5_open(token: str) -> dict[str, Any]:
|
||||
"""
|
||||
return {"status": "ok", "channel": "h5", "note": "scaffold-only"}
|
||||
打开补问页:校验 token 未过期。
|
||||
|
||||
返回允许动作与 wait_version;不返回内部异常栈。
|
||||
"""
|
||||
rec = validate_token(token)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||||
return {
|
||||
"status": "ok",
|
||||
"thread_id": rec.thread_id,
|
||||
"wait_version": rec.wait_version,
|
||||
"allowed_actions": rec.allowed_actions,
|
||||
"note": "骨架:字段列表后续按合同生成",
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{token}/submit")
|
||||
def h5_submit(token: str, body: H5SubmitBody) -> dict[str, Any]:
|
||||
"""
|
||||
提交补问:消费 token,转入 card_action 骨架(不跑整图)。
|
||||
|
||||
副作用:token 一次性;入队/Handler 后续接 Worker。
|
||||
"""
|
||||
rec = validate_token(token, action=body.action or "submit_clarify", consume=True)
|
||||
if rec is None:
|
||||
raise HTTPException(status_code=410, detail="表单已过期或无效,请回企微重新打开")
|
||||
handle_card_action(
|
||||
sender_id=rec.sender_id,
|
||||
thread_id=rec.thread_id,
|
||||
wait_version=rec.wait_version,
|
||||
action=body.action or "submit_clarify",
|
||||
payload=body.fields,
|
||||
)
|
||||
logger.info(
|
||||
"h5.submit ok thread=%s wait=%s action=%s",
|
||||
rec.thread_id,
|
||||
rec.wait_version,
|
||||
body.action,
|
||||
)
|
||||
return {"status": "accepted", "thread_id": rec.thread_id}
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
"""
|
||||
PostgreSQL inbox / outbox 账本。
|
||||
|
||||
本文件职责:落 agent_inbox / agent_outbox;按条认领 + 超时回收。
|
||||
调用:需已执行 migrations/001_runtime_core_v1.sql。
|
||||
禁止:在回调线程跑长事务;单次操作短 SQL。
|
||||
线程:每个方法短连接或共用连接需自行串行;本实现每操作用独立短连接,避免跨线程共享 cursor。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.channel.queue import InboxItem, OutboxItem, TaskState
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class PostgresMessageStore:
|
||||
"""
|
||||
PG 消息账本。
|
||||
|
||||
claim_timeout_sec:认领超时后可被其他消费者回收。
|
||||
claimed_by:本进程标识,便于排查。
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
dsn: str,
|
||||
*,
|
||||
claim_timeout_sec: float = 120.0,
|
||||
worker_id: str = "",
|
||||
) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
self._claim_timeout = claim_timeout_sec
|
||||
self._worker_id = worker_id or f"pid-{threading.get_ident()}"
|
||||
self._inbox_event = threading.Event()
|
||||
self._outbox_event = threading.Event()
|
||||
|
||||
def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]:
|
||||
"""写入 agent_inbox;message_id 唯一冲突视为重复。"""
|
||||
if not message.has_sender():
|
||||
return False, "missing_sender_id"
|
||||
item_id = str(uuid.uuid4())
|
||||
payload = json.dumps(message.to_dict(), ensure_ascii=False)
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_inbox (
|
||||
id, message_id, sender_id, chat_id, chat_type, payload, state
|
||||
) VALUES (%s, %s, %s, %s, %s, %s::jsonb, 'pending')
|
||||
""",
|
||||
(
|
||||
item_id,
|
||||
message.message_id,
|
||||
message.sender_id,
|
||||
message.chat_id,
|
||||
message.chat_type,
|
||||
payload,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
self._inbox_event.set()
|
||||
logger.info(
|
||||
"pg inbox 入队 id=%s msg_id=%s sender=%s",
|
||||
item_id,
|
||||
message.message_id,
|
||||
message.sender_id,
|
||||
)
|
||||
return True, item_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
# unique_violation
|
||||
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
return False, "duplicate_message_id"
|
||||
logger.exception("pg inbox 入队失败")
|
||||
raise
|
||||
|
||||
def claim_inbound(self) -> Optional[InboxItem]:
|
||||
"""认领一条 pending 或超时 claimed。"""
|
||||
timeout = int(self._claim_timeout)
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_inbox
|
||||
WHERE state = 'pending'
|
||||
OR (
|
||||
state = 'claimed'
|
||||
AND claimed_at IS NOT NULL
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_inbox i
|
||||
SET state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte
|
||||
WHERE i.id = cte.id
|
||||
RETURNING i.id, i.payload, i.attempts, i.last_error, i.claimed_at
|
||||
""",
|
||||
(str(timeout), self._worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
self._inbox_event.clear()
|
||||
return None
|
||||
payload = row[1]
|
||||
if isinstance(payload, str):
|
||||
payload = json.loads(payload)
|
||||
msg = InboundMessage.from_dict(dict(payload))
|
||||
claimed_at = row[4]
|
||||
ts = claimed_at.timestamp() if hasattr(claimed_at, "timestamp") else time.time()
|
||||
return InboxItem(
|
||||
id=str(row[0]),
|
||||
message=msg,
|
||||
state=TaskState.CLAIMED,
|
||||
claimed_at=ts,
|
||||
attempts=int(row[2] or 0),
|
||||
last_error=str(row[3] or ""),
|
||||
)
|
||||
|
||||
def complete_inbound(self, item_id: str, *, error: str = "") -> None:
|
||||
state = "failed" if error else "done"
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_inbox
|
||||
SET state = %s, last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(state, error or "", item_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def enqueue_outbound(
|
||||
self,
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
if not touser or not content:
|
||||
return False, "invalid_outbox"
|
||||
item_id = str(uuid.uuid4())
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_outbox (
|
||||
id, dedupe_key, touser, content, payload, state
|
||||
) VALUES (%s, %s, %s, %s, '{}'::jsonb, 'pending')
|
||||
""",
|
||||
(item_id, dedupe_key or "", touser, content),
|
||||
)
|
||||
conn.commit()
|
||||
self._outbox_event.set()
|
||||
logger.info("pg outbox 入队 id=%s touser=%s", item_id, touser)
|
||||
return True, item_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if dedupe_key and (
|
||||
"unique" in str(exc).lower() or "duplicate" in str(exc).lower()
|
||||
):
|
||||
return False, "duplicate_outbox"
|
||||
logger.exception("pg outbox 入队失败")
|
||||
raise
|
||||
|
||||
def claim_outbound(self) -> Optional[OutboxItem]:
|
||||
timeout = int(self._claim_timeout)
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_outbox
|
||||
WHERE state = 'pending'
|
||||
OR (
|
||||
state = 'claimed'
|
||||
AND claimed_at IS NOT NULL
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_outbox o
|
||||
SET state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte
|
||||
WHERE o.id = cte.id
|
||||
RETURNING o.id, o.touser, o.content, o.attempts, o.last_error,
|
||||
o.created_at, o.dedupe_key, o.claimed_at
|
||||
""",
|
||||
(str(timeout), self._worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
self._outbox_event.clear()
|
||||
return None
|
||||
created = row[5]
|
||||
created_ts = created.timestamp() if hasattr(created, "timestamp") else time.time()
|
||||
claimed = row[7]
|
||||
claimed_ts = claimed.timestamp() if hasattr(claimed, "timestamp") else time.time()
|
||||
return OutboxItem(
|
||||
id=str(row[0]),
|
||||
touser=str(row[1]),
|
||||
content=str(row[2]),
|
||||
state=TaskState.CLAIMED,
|
||||
claimed_at=claimed_ts,
|
||||
attempts=int(row[3] or 0),
|
||||
last_error=str(row[4] or ""),
|
||||
created_at=created_ts,
|
||||
dedupe_key=str(row[6] or ""),
|
||||
)
|
||||
|
||||
def complete_outbound(
|
||||
self, item_id: str, *, error: str = "", permanent: bool = False
|
||||
) -> None:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
if error:
|
||||
row = conn.execute(
|
||||
"SELECT attempts FROM agent_outbox WHERE id = %s",
|
||||
(item_id,),
|
||||
).fetchone()
|
||||
attempts = int(row[0]) if row else 0
|
||||
if permanent or attempts >= 5:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'failed', last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(error, item_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'pending', claimed_at = NULL, claimed_by = '',
|
||||
last_error = %s, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(error, item_id),
|
||||
)
|
||||
self._outbox_event.set()
|
||||
else:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_outbox
|
||||
SET state = 'done', last_error = '', updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(item_id,),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
def wait_inbox(self, timeout: float = 1.0) -> None:
|
||||
self._inbox_event.wait(timeout=timeout)
|
||||
|
||||
def wait_outbox(self, timeout: float = 1.0) -> None:
|
||||
self._outbox_event.wait(timeout=timeout)
|
||||
|
||||
def stats(self) -> dict[str, Any]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
inbox = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_inbox WHERE state IN ('pending','claimed')"
|
||||
).fetchone()
|
||||
outbox = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_outbox WHERE state IN ('pending','claimed')"
|
||||
).fetchone()
|
||||
return {
|
||||
"backend": "postgres",
|
||||
"inbox": int(inbox[0] if inbox else 0),
|
||||
"outbox": int(outbox[0] if outbox else 0),
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
"""
|
||||
进程内 inbox / outbox 队列(骨架过渡实现)。
|
||||
进程内 / PostgreSQL inbox·outbox 账本。
|
||||
|
||||
本文件职责:回调快写入队、按条认领、message_id 去重;后续可替换为 PostgreSQL 表。
|
||||
本文件职责:定义任务结构、Memory 实现、按配置选择后端工厂。
|
||||
禁止:Redis 全局 Worker 单例锁;禁止在回调线程里等 LLM。
|
||||
为何先用内存:先打通「收消息→回复」联调;落 PG 迁移不挡本迭代验收。
|
||||
有 PG_DSN 且 MESSAGE_STORE_BACKEND=postgres|auto 时用表;否则内存(本机联调)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -15,7 +15,7 @@ import uuid
|
||||
from collections import OrderedDict
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Any, Optional
|
||||
from typing import Any, Optional, Union
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
@@ -207,6 +207,7 @@ class MemoryMessageStore:
|
||||
def stats(self) -> dict[str, Any]:
|
||||
with self._lock:
|
||||
return {
|
||||
"backend": "memory",
|
||||
"inbox": len(self._inbox),
|
||||
"outbox": len(self._outbox),
|
||||
"seen_msg": len(self._seen_msg),
|
||||
@@ -218,15 +219,55 @@ class MemoryMessageStore:
|
||||
store.popitem(last=False)
|
||||
|
||||
|
||||
# 进程内单例:HTTP 回调与消费线程共享(同进程)。多进程部署时必须换 PG。
|
||||
_STORE: Optional[MemoryMessageStore] = None
|
||||
MessageStoreImpl = Union[MemoryMessageStore, Any]
|
||||
|
||||
_STORE: Optional[MessageStoreImpl] = None
|
||||
_STORE_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def get_message_store() -> MemoryMessageStore:
|
||||
"""获取进程内消息账本单例。"""
|
||||
def get_message_store() -> MessageStoreImpl:
|
||||
"""
|
||||
获取消息账本单例。
|
||||
|
||||
MESSAGE_STORE_BACKEND=postgres 且 PG 可用 → PostgresMessageStore;
|
||||
auto 有 DSN 则尝试 PG,失败回退内存;memory 强制内存。
|
||||
"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
if _STORE is None:
|
||||
_STORE = MemoryMessageStore()
|
||||
_STORE = _create_store()
|
||||
return _STORE
|
||||
|
||||
|
||||
def reset_message_store_for_tests() -> None:
|
||||
"""仅自检用:清空单例。"""
|
||||
global _STORE
|
||||
with _STORE_LOCK:
|
||||
_STORE = None
|
||||
|
||||
|
||||
def _create_store() -> MessageStoreImpl:
|
||||
from agent.config import get_settings
|
||||
|
||||
settings = get_settings()
|
||||
backend = (getattr(settings, "message_store_backend", None) or "auto").strip().lower()
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
|
||||
use_pg = backend == "postgres" or (backend == "auto" and bool(dsn))
|
||||
if use_pg and dsn:
|
||||
try:
|
||||
from agent.channel.pg_store import PostgresMessageStore
|
||||
from agent.runtime_db import assert_safe_database
|
||||
|
||||
assert_safe_database(dsn)
|
||||
store = PostgresMessageStore(dsn)
|
||||
store.stats()
|
||||
logger.info("消息账本后端=postgres")
|
||||
return store
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if backend == "postgres":
|
||||
raise
|
||||
logger.warning("PG 消息账本不可用,回退内存:%s", exc)
|
||||
|
||||
logger.info("消息账本后端=memory")
|
||||
return MemoryMessageStore()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
通道消费循环:inbox → echo handler;outbox → 企微发送。
|
||||
通道消费循环:inbox → RouteDecision → dispatch → handler;outbox → 企微发送。
|
||||
|
||||
本文件职责:在 HTTP 进程内独立线程跑快路径,不与 LibreOffice 串队。
|
||||
禁止:在回调请求线程调用本循环的阻塞逻辑;禁止 Redis 全局单例锁。
|
||||
@@ -12,9 +12,10 @@ import threading
|
||||
from typing import Optional
|
||||
|
||||
from agent.channel.outbox.sender import PERMANENT_SEND_ERRORS, WeComAppClient
|
||||
from agent.channel.queue import MemoryMessageStore, get_message_store
|
||||
from agent.channel.queue import get_message_store
|
||||
from agent.config import Settings
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
from agent.routing import IntentRouter
|
||||
from agent.routing.dispatch import dispatch_inbound
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
# 避免 httpx 把 access_token / secret 打进 INFO
|
||||
@@ -26,6 +27,7 @@ class ChannelRuntime:
|
||||
企微通道运行时:inbox/outbox 各一条守护线程。
|
||||
|
||||
启动时机:FastAPI lifespan;停止:进程退出。
|
||||
inbox:IntentRouter.route(默认 stub)→ dispatch 查表;无业务分支森林。
|
||||
"""
|
||||
|
||||
def __init__(self, settings: Settings) -> None:
|
||||
@@ -33,6 +35,7 @@ class ChannelRuntime:
|
||||
self._store = get_message_store()
|
||||
self._stop = threading.Event()
|
||||
self._threads: list[threading.Thread] = []
|
||||
self._router = IntentRouter(allow_network=False)
|
||||
agent_id = int(settings.wecom_agent_id or "0") or 0
|
||||
self._client = WeComAppClient(
|
||||
corp_id=settings.wecom_corp_id,
|
||||
@@ -41,7 +44,7 @@ class ChannelRuntime:
|
||||
)
|
||||
|
||||
@property
|
||||
def store(self) -> MemoryMessageStore:
|
||||
def store(self):
|
||||
return self._store
|
||||
|
||||
def start(self) -> None:
|
||||
@@ -53,7 +56,7 @@ class ChannelRuntime:
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程)")
|
||||
logger.info("ChannelRuntime 已启动(inbox/outbox 独立线程;dispatch 分发)")
|
||||
|
||||
def stop(self) -> None:
|
||||
self._stop.set()
|
||||
@@ -65,12 +68,11 @@ class ChannelRuntime:
|
||||
self._store.wait_inbox(1.0)
|
||||
continue
|
||||
try:
|
||||
handle_echo_text(
|
||||
item.message,
|
||||
self._store,
|
||||
ytd_env=self._settings.ytd_env,
|
||||
reply_template=self._settings.wecom_callback_reply_text,
|
||||
decision = self._router.route(
|
||||
text=item.message.content or "",
|
||||
sender_id=item.message.sender_id or "",
|
||||
)
|
||||
dispatch_inbound(item.message, decision)
|
||||
self._store.complete_inbound(item.id)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("inbox 处理失败 id=%s", item.id)
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
消息账本协议:inbox / outbox 认领接口。
|
||||
|
||||
Memory 与 Postgres 实现共用此协议,通道运行时不依赖具体后端。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, Protocol
|
||||
|
||||
from agent.channel.queue import InboxItem, OutboxItem
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
|
||||
class MessageStore(Protocol):
|
||||
"""inbox/outbox 账本协议。"""
|
||||
|
||||
def enqueue_inbound(self, message: InboundMessage) -> tuple[bool, str]: ...
|
||||
|
||||
def claim_inbound(self) -> Optional[InboxItem]: ...
|
||||
|
||||
def complete_inbound(self, item_id: str, *, error: str = "") -> None: ...
|
||||
|
||||
def enqueue_outbound(
|
||||
self,
|
||||
*,
|
||||
touser: str,
|
||||
content: str,
|
||||
dedupe_key: str = "",
|
||||
) -> tuple[bool, str]: ...
|
||||
|
||||
def claim_outbound(self) -> Optional[OutboxItem]: ...
|
||||
|
||||
def complete_outbound(
|
||||
self, item_id: str, *, error: str = "", permanent: bool = False
|
||||
) -> None: ...
|
||||
|
||||
def wait_inbox(self, timeout: float = 1.0) -> None: ...
|
||||
|
||||
def wait_outbox(self, timeout: float = 1.0) -> None: ...
|
||||
|
||||
def stats(self) -> dict[str, Any]: ...
|
||||
@@ -35,3 +35,34 @@ class InboundMessage:
|
||||
def has_sender(self) -> bool:
|
||||
"""是否具备可进入业务的发送人身份。"""
|
||||
return bool(self.sender_id and self.sender_id.strip())
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""序列化进 PG JSONB;不含密钥。"""
|
||||
return {
|
||||
"sender_id": self.sender_id,
|
||||
"message_id": self.message_id,
|
||||
"content": self.content,
|
||||
"msg_type": self.msg_type,
|
||||
"chat_id": self.chat_id,
|
||||
"chat_type": self.chat_type,
|
||||
"agent_id": self.agent_id,
|
||||
"create_time": self.create_time,
|
||||
"media": self.media,
|
||||
"raw": self.raw,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: dict[str, Any]) -> "InboundMessage":
|
||||
"""从 JSONB / dict 还原。"""
|
||||
return cls(
|
||||
sender_id=str(data.get("sender_id") or ""),
|
||||
message_id=str(data.get("message_id") or ""),
|
||||
content=str(data.get("content") or ""),
|
||||
msg_type=str(data.get("msg_type") or "text"),
|
||||
chat_id=str(data.get("chat_id") or ""),
|
||||
chat_type=str(data.get("chat_type") or "single"),
|
||||
agent_id=str(data.get("agent_id") or ""),
|
||||
create_time=int(data.get("create_time") or 0),
|
||||
media=dict(data.get("media") or {}),
|
||||
raw=dict(data.get("raw") or {}),
|
||||
)
|
||||
|
||||
@@ -41,8 +41,14 @@ class Settings(BaseSettings):
|
||||
ytd_env: str = Field(default="test", alias="YTD_ENV")
|
||||
agent_http_port: int = Field(default=8910, alias="AGENT_HTTP_PORT")
|
||||
public_base_url: str = Field(default="", alias="PUBLIC_BASE_URL")
|
||||
biz_api_base_url: str = Field(default="http://127.0.0.1:8180", alias="BIZ_API_BASE_URL")
|
||||
biz_api_base_url: str = Field(default="http://127.0.0.1:8180/jeecgboot", alias="BIZ_API_BASE_URL")
|
||||
biz_api_token: str = Field(default="", alias="BIZ_API_TOKEN")
|
||||
biz_api_username: str = Field(default="", alias="BIZ_API_USERNAME")
|
||||
biz_api_password: str = Field(default="", alias="BIZ_API_PASSWORD")
|
||||
biz_api_timeout_seconds: float = Field(default=10.0, alias="BIZ_API_TIMEOUT_SECONDS")
|
||||
agent_callback_token: str = Field(default="", alias="AGENT_CALLBACK_TOKEN")
|
||||
# 兼容现网变量名
|
||||
inquiry_agent_callback_token: str = Field(default="", alias="INQUIRY_AGENT_CALLBACK_TOKEN")
|
||||
|
||||
# 企微询价应用(与正式同一套应用 ID;密钥只从环境读)
|
||||
wecom_corp_id: str = Field(default="", alias="WECOM_CORP_ID")
|
||||
@@ -62,12 +68,78 @@ class Settings(BaseSettings):
|
||||
pg_database: str = Field(default="", alias="PG_DATABASE")
|
||||
pg_user: str = Field(default="", alias="PG_USER")
|
||||
pg_password: str = Field(default="", alias="PG_PASSWORD")
|
||||
pg_sslmode: str = Field(default="prefer", alias="PG_SSLMODE")
|
||||
# 可直接给完整 DSN;为空则由 host/port/user 等拼装
|
||||
pg_dsn: str = Field(default="", alias="PG_DSN")
|
||||
# postgres | memory;正式必须 postgres;memory 仅本地骨架冒烟
|
||||
checkpoint_backend: str = Field(default="postgres", alias="CHECKPOINT_BACKEND")
|
||||
|
||||
redis_url: str = Field(default="", alias="REDIS_URL")
|
||||
redis_key_prefix: str = Field(default="inquiry_robot:", alias="REDIS_KEY_PREFIX")
|
||||
# redis | memory;正式必须 redis + URL 指向逻辑库 /1(与现网 /0 隔离)
|
||||
redis_backend: str = Field(default="redis", alias="REDIS_BACKEND")
|
||||
redis_required: bool = Field(default=False, alias="REDIS_REQUIRED")
|
||||
# auto | memory | postgres;auto=有 PG_DSN 则尝试 PG
|
||||
message_store_backend: str = Field(default="auto", alias="MESSAGE_STORE_BACKEND")
|
||||
wecom_aibot_bridge_token: str = Field(default="", alias="WECOM_AIBOT_BRIDGE_TOKEN")
|
||||
wecom_aibot_ingress_url: str = Field(
|
||||
default="http://127.0.0.1:8910/internal/aibot/inbound",
|
||||
alias="WECOM_AIBOT_INGRESS_URL",
|
||||
)
|
||||
|
||||
deepseek_api_key: str = Field(default="", alias="DEEPSEEK_API_KEY")
|
||||
deepseek_base_url: str = Field(default="https://api.deepseek.com", alias="DEEPSEEK_BASE_URL")
|
||||
deepseek_model: str = Field(default="", alias="DEEPSEEK_MODEL")
|
||||
qwen_api_key: str = Field(default="", alias="QWEN_API_KEY")
|
||||
qwen_base_url: str = Field(default="", alias="QWEN_BASE_URL")
|
||||
qwen_text_model: str = Field(default="", alias="QWEN_TEXT_MODEL")
|
||||
qwen_vl_model: str = Field(default="", alias="QWEN_VL_MODEL")
|
||||
# prompt/04:未确认不得打真实模型(出站 HTTP 客户端强制检查)
|
||||
llm_data_usage_confirmed: bool = Field(default=False, alias="LLM_DATA_USAGE_CONFIRMED")
|
||||
# Worker 槽是否允许对 ordered_actions 走真网(仍受上一开关约束)
|
||||
llm_allow_network: bool = Field(default=False, alias="LLM_ALLOW_NETWORK")
|
||||
|
||||
# LibreOffice:默认 Windows 安装路径;Linux 可改 /usr/bin/libreoffice
|
||||
libreoffice_path: str = Field(
|
||||
default=r"C:\Program Files\LibreOffice\program\soffice.exe",
|
||||
alias="LIBREOFFICE_PATH",
|
||||
)
|
||||
libreoffice_timeout_seconds: float = Field(
|
||||
default=120.0, alias="LIBREOFFICE_TIMEOUT_SECONDS"
|
||||
)
|
||||
|
||||
object_storage_endpoint_url: str = Field(default="", alias="OBJECT_STORAGE_ENDPOINT_URL")
|
||||
object_storage_bucket: str = Field(default="inquiry-robot", alias="OBJECT_STORAGE_BUCKET")
|
||||
object_storage_access_key: str = Field(default="", alias="OBJECT_STORAGE_ACCESS_KEY")
|
||||
object_storage_secret_key: str = Field(default="", alias="OBJECT_STORAGE_SECRET_KEY")
|
||||
object_storage_path_style: bool = Field(default=True, alias="OBJECT_STORAGE_PATH_STYLE")
|
||||
|
||||
libreoffice_slots: int = Field(default=1, alias="LIBREOFFICE_SLOTS")
|
||||
http_job_slots: int = Field(default=3, alias="HTTP_JOB_SLOTS")
|
||||
|
||||
def resolve_pg_dsn(self) -> str:
|
||||
"""
|
||||
解析 PostgreSQL 连接串(运行账 / Graph checkpoint)。
|
||||
|
||||
返回值:PG_DSN 或由分量拼出的 URI;缺关键字段时返回空串。
|
||||
禁止:连现网 ytd_runtime(由部署/环境保证库名,代码不写死切换)。
|
||||
"""
|
||||
raw = (self.pg_dsn or "").strip()
|
||||
if raw:
|
||||
return raw
|
||||
if not (self.pg_host and self.pg_database and self.pg_user):
|
||||
return ""
|
||||
# 密码可为空(本机信任);sslmode 走环境
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
user = quote_plus(self.pg_user)
|
||||
password = quote_plus(self.pg_password or "")
|
||||
auth = f"{user}:{password}@" if self.pg_password else f"{user}@"
|
||||
return (
|
||||
f"postgresql://{auth}{self.pg_host}:{self.pg_port}/"
|
||||
f"{self.pg_database}?sslmode={self.pg_sslmode or 'prefer'}"
|
||||
)
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def get_settings() -> Settings:
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
基础框架联合自检(不连真实 PG/外网模型也可跑)。
|
||||
|
||||
覆盖:迁移、正式合同、ledger、llm、DeepSeek A 解析、LibreOffice 探测、
|
||||
识别/存档入队、routing、storage。
|
||||
用法:
|
||||
set REDIS_BACKEND=memory
|
||||
set CHECKPOINT_BACKEND=memory
|
||||
python -m agent.foundation_selftest
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
os.environ["REDIS_BACKEND"] = "memory"
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("REDIS_KEY_PREFIX", "inquiry_robot:")
|
||||
os.environ["LLM_DATA_USAGE_CONFIRMED"] = "false"
|
||||
os.environ["LLM_ALLOW_NETWORK"] = "false"
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.jobs.archive import enqueue_archive
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter
|
||||
from agent.jobs.recognition import enqueue_recognition
|
||||
from agent.ledger import LedgerClient
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.redis_coord import shutdown_redis_runtime
|
||||
from agent.routing import IntentRouter, ordered_actions_tool_definition, parse_ordered_actions_arguments
|
||||
from agent.runtime_db import assert_safe_database, list_migration_files
|
||||
from agent.schema import load_contract, require_formal_contract
|
||||
from agent.storage import ObjectStorageClient
|
||||
|
||||
get_settings.cache_clear()
|
||||
settings = get_settings()
|
||||
|
||||
files = list_migration_files()
|
||||
assert "001_runtime_core_v1.sql" in files, files
|
||||
assert "002_dead_letter_v1.sql" in files, files
|
||||
logger.info("OK: migrations %s", files)
|
||||
|
||||
try:
|
||||
assert_safe_database("postgresql://u:p@127.0.0.1:5432/ytd_runtime")
|
||||
logger.error("应拒绝 ytd_runtime")
|
||||
return 1
|
||||
except RuntimeError:
|
||||
logger.info("OK: 拒绝现网库名 ytd_runtime")
|
||||
|
||||
contract = load_contract("inquiry-required-fields-v1")
|
||||
assert contract["_meta"]["is_placeholder"] is False, contract["_meta"]
|
||||
formal = require_formal_contract("inquiry-required-fields-v1")
|
||||
assert formal.get("contract_id") or formal.get("schema_version") is not None
|
||||
tms = require_formal_contract("tms-dictionary-projection-v2")
|
||||
assert tms["_meta"]["is_placeholder"] is False
|
||||
logger.info("OK: 正式合同已加载 fields+tms")
|
||||
|
||||
tool = ordered_actions_tool_definition()
|
||||
assert tool["function"]["name"] == "route_decision"
|
||||
digest = "a" * 64
|
||||
parsed = parse_ordered_actions_arguments(
|
||||
{
|
||||
"decision_id": "D-1",
|
||||
"intent": "ordinary_text_inquiry",
|
||||
"ordered_action": {
|
||||
"action_type": "ORDINARY_TEXT_INQUIRY",
|
||||
"order": 1,
|
||||
"target": {"work_order_no": ""},
|
||||
"evidence": {"source_ref": "inbound:u1", "content_digest": digest},
|
||||
},
|
||||
}
|
||||
)
|
||||
assert parsed.intent == "ordinary_text_inquiry"
|
||||
logger.info("OK: ordered_actions 解析")
|
||||
|
||||
ledger = LedgerClient.from_settings(settings)
|
||||
assert "8180" in ledger.base_url or ledger.base_url
|
||||
logger.info("OK: ledger client base=%s", ledger.base_url)
|
||||
ping = ledger.ping()
|
||||
logger.info("ledger.ping ok=%s status=%s", ping.ok, ping.status_code)
|
||||
ledger.close()
|
||||
|
||||
gw = LlmGateway.from_settings(settings)
|
||||
enq = gw.enqueue(
|
||||
mode=LlmMode.ORDERED_ACTIONS,
|
||||
payload={"text": "hi"},
|
||||
idempotency_key="foundation-1",
|
||||
)
|
||||
assert enq.accepted, enq
|
||||
stub = gw.invoke_stub(mode=LlmMode.EXTRACT_FIELDS, payload={"a": 1})
|
||||
assert stub.get("stub") is True
|
||||
assert stub.get("error") == "extract_fields_shell_not_implemented"
|
||||
from agent.llm import list_mode_shells
|
||||
|
||||
assert "vision" in list_mode_shells()
|
||||
logger.info("OK: llm enqueue/mode shells")
|
||||
|
||||
decision = IntentRouter().route_stub(text="询价", sender_id="u1")
|
||||
assert decision.intent == "ordinary_text_inquiry", decision.intent
|
||||
assert IntentRouter().route(text="询价", sender_id="").intent == "reject_no_sender"
|
||||
logger.info("OK: DeepSeek A stub 路由")
|
||||
|
||||
from agent.channel.clarification import issue_token, validate_token
|
||||
from agent.channel.queue import reset_message_store_for_tests
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
reset_message_store_for_tests()
|
||||
os.environ["MESSAGE_STORE_BACKEND"] = "memory"
|
||||
get_settings.cache_clear()
|
||||
reset_message_store_for_tests()
|
||||
from agent.channel.queue import get_message_store
|
||||
|
||||
store = get_message_store()
|
||||
assert store.stats().get("backend") == "memory"
|
||||
msg = InboundMessage(sender_id="u1", message_id="m-foundation-1", content="hello")
|
||||
ok, iid = store.enqueue_inbound(msg)
|
||||
assert ok, iid
|
||||
claimed = store.claim_inbound()
|
||||
assert claimed is not None
|
||||
store.complete_inbound(claimed.id)
|
||||
logger.info("OK: memory inbox claim")
|
||||
|
||||
tok = issue_token(
|
||||
thread_id="t1",
|
||||
wait_version=1,
|
||||
sender_id="u1",
|
||||
allowed_actions=["submit_clarify"],
|
||||
)
|
||||
rec = validate_token(tok, action="submit_clarify", consume=True)
|
||||
assert rec is not None and rec.thread_id == "t1"
|
||||
assert validate_token(tok) is None
|
||||
logger.info("OK: clarification token")
|
||||
|
||||
from agent.graph import GraphRuntime, shutdown_graph_runtime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.jobs.wake_consumer import claim_and_apply_wake, enqueue_wake, wake_stream
|
||||
from agent.llm import LlmHttpClient
|
||||
from agent.redis_coord import get_redis_runtime
|
||||
|
||||
os.environ["CHECKPOINT_BACKEND"] = "memory"
|
||||
get_settings.cache_clear()
|
||||
gr = GraphRuntime.create(get_settings())
|
||||
started = gr.start_thread(sender_id="u1")
|
||||
tid = started["thread_id"]
|
||||
rr = get_redis_runtime(get_settings())
|
||||
entry = enqueue_wake(
|
||||
rr.client,
|
||||
WorkerWakePayload(
|
||||
thread_id=tid,
|
||||
inquiry_no="F-1",
|
||||
quote_version=1,
|
||||
wait_version=0,
|
||||
kind="recognition_done",
|
||||
),
|
||||
)
|
||||
assert entry
|
||||
q = wake_stream(rr.client)
|
||||
assert claim_and_apply_wake(queue=q, graph=gr, consumer="selftest")
|
||||
logger.info("OK: wake enqueue/apply")
|
||||
|
||||
http_llm = LlmHttpClient.from_settings(get_settings())
|
||||
no_net = http_llm.chat(mode="ordered_actions", messages=[{"role": "user", "content": "hi"}])
|
||||
assert no_net.ok is False and no_net.error == "llm_data_usage_not_confirmed"
|
||||
logger.info("OK: llm http 拒绝未确认数据用途")
|
||||
|
||||
recog = enqueue_recognition(
|
||||
kind="attachment_text",
|
||||
payload={"object_key": "stub"},
|
||||
idempotency_key="recog-1",
|
||||
redis=rr,
|
||||
)
|
||||
assert recog.accepted, recog
|
||||
arch = enqueue_archive(payload={"msg_id": "m1"}, idempotency_key="arch-1", redis=rr)
|
||||
assert arch.accepted, arch
|
||||
logger.info("OK: recognition/archive enqueue")
|
||||
|
||||
lo = LibreOfficeConverter.from_settings(get_settings())
|
||||
logger.info("OK: LibreOffice available=%s path=%s", lo.available(), lo.executable())
|
||||
|
||||
gr.close()
|
||||
shutdown_graph_runtime()
|
||||
|
||||
storage = ObjectStorageClient.from_settings(settings)
|
||||
put_ok = storage.put_bytes_stub(key="x", data=b"1")
|
||||
assert put_ok is storage.configured()
|
||||
logger.info("OK: storage stub configured=%s put_ok=%s", storage.configured(), put_ok)
|
||||
|
||||
from agent.jobs.dead_letter import enqueue_dead_letter
|
||||
from agent.jobs.pg_job import create_job_store
|
||||
from agent.ledger.api_shell import LedgerApiShell
|
||||
from agent.policy import assert_has_sender_id, policy_package_ready
|
||||
from agent.quote_templates import list_template_registry
|
||||
from agent.routing.dispatch import HANDLER_REGISTRY, dispatch_inbound
|
||||
from agent.runtime_db import SessionRecord, create_session_store, create_suspend_store
|
||||
from agent.runtime_db.suspend_store import SuspendRecord
|
||||
from agent.schema import validate_required_fields_shell
|
||||
from agent.storage.s3_client import S3ObjectStorage
|
||||
|
||||
assert policy_package_ready()
|
||||
assert assert_has_sender_id("u1")["ok"] is True
|
||||
assert assert_has_sender_id("")["ok"] is False
|
||||
|
||||
sess = create_session_store(backend="memory")
|
||||
sess.upsert(SessionRecord(thread_id="t-shell", sender_id="u1"))
|
||||
assert sess.get("t-shell") is not None
|
||||
|
||||
sus = create_suspend_store(backend="memory")
|
||||
sus.open_wait(SuspendRecord(thread_id="t-shell", wait_version=1, pause_name="pause_parse_clarify"))
|
||||
assert sus.get("t-shell", 1) is not None
|
||||
assert sus.close_wait("t-shell", 1) is True
|
||||
|
||||
jobs = create_job_store(backend="memory")
|
||||
ok_j, jid = jobs.enqueue(job_type="shell", payload={"x": 1}, idempotency_key="j1")
|
||||
assert ok_j, jid
|
||||
claimed_j = jobs.claim(worker_id="selftest")
|
||||
assert claimed_j is not None
|
||||
jobs.complete(claimed_j.id)
|
||||
|
||||
dl = enqueue_dead_letter(source="selftest", error="demo", job_type="shell")
|
||||
assert dl
|
||||
|
||||
assert "ordinary_text_inquiry" in HANDLER_REGISTRY
|
||||
d2 = IntentRouter().route_stub(text="x", sender_id="u1")
|
||||
msg2 = InboundMessage(sender_id="u1", message_id="m-dispatch", content="x")
|
||||
# dispatch 可能写 outbox(echo);仅验证查表调用不抛
|
||||
dispatch_inbound(msg2, d2)
|
||||
|
||||
shell_api = LedgerApiShell(LedgerClient.from_settings(get_settings()))
|
||||
assert shell_api.create_ticket(payload={}, idempotency_key="x").status_code == 501
|
||||
|
||||
from agent.quote_templates import list_templates_from_ledger
|
||||
|
||||
lt = list_templates_from_ledger(LedgerClient.from_settings(get_settings()))
|
||||
assert lt.status_code == 501
|
||||
assert lt.error and "list_quote_templates" in lt.error
|
||||
|
||||
vf = validate_required_fields_shell(facts={}, require_formal=True)
|
||||
assert vf.get("implemented") is False
|
||||
|
||||
_ = list_template_registry()
|
||||
s3 = S3ObjectStorage.from_settings(get_settings(), allow_network=False)
|
||||
put_s3 = s3.put_bytes(key="k", data=b"1")
|
||||
assert put_s3.get("dry_run") is True or put_s3.get("error") in {
|
||||
"not_configured",
|
||||
"boto3_not_installed",
|
||||
}
|
||||
logger.info("OK: session/suspend/job/dead_letter/dispatch/ledger/schema/s3 shells")
|
||||
|
||||
shutdown_redis_runtime()
|
||||
logger.info("基础框架联合自检通过")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -1,6 +1,29 @@
|
||||
"""
|
||||
LangGraph 编排:图定义与节点分文件;checkpoint 在 PostgreSQL。
|
||||
LangGraph 编排包:图定义、节点分文件、pause/resume、结构化 wake。
|
||||
|
||||
禁止:单文件堆全部节点;禁止在本包处理企微 HTTP 或 LibreOffice。
|
||||
骨架:仅包占位,不注册图。
|
||||
本包职责(对齐 prompt/14、06):
|
||||
- 一单一个 thread_id;checkpoint 在 PostgreSQL(可开发降级 Memory);
|
||||
- 停点 interrupt_before;Worker 槽内 start/resume/wake;
|
||||
- 无 sender_id 不进图。
|
||||
|
||||
禁止:企微 HTTP、LibreOffice、主账六态真相、单文件堆全部节点业务。
|
||||
骨架:节点仅打日志;HTTP 回调进程不要 import 后 invoke 整图。
|
||||
"""
|
||||
|
||||
from agent.graph.runtime import (
|
||||
GraphIdentityError,
|
||||
GraphRuntime,
|
||||
GraphWakeError,
|
||||
get_graph_runtime,
|
||||
shutdown_graph_runtime,
|
||||
)
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
|
||||
__all__ = [
|
||||
"GraphIdentityError",
|
||||
"GraphRuntime",
|
||||
"GraphWakeError",
|
||||
"WorkerWakePayload",
|
||||
"get_graph_runtime",
|
||||
"shutdown_graph_runtime",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
装配询价 StateGraph(骨架线性流水线)。
|
||||
|
||||
本文件职责:把分文件节点串成一张图并 compile;不写业务分支。
|
||||
停点:interrupt_before=INTERRUPT_BEFORE(见 pauses.py)。
|
||||
禁止:单文件堆全部节点实现;禁止在本文件调企微 / soffice。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from langgraph.graph import END, START, StateGraph
|
||||
|
||||
from agent.graph import nodes as graph_nodes
|
||||
from agent.graph.pauses import (
|
||||
INTERRUPT_BEFORE,
|
||||
PAUSE_ADOPT_ADJUST,
|
||||
PAUSE_CLOSE_DEAL,
|
||||
PAUSE_COLLABORATE,
|
||||
PAUSE_CREATE_ORDER,
|
||||
PAUSE_GENERATE_QUOTE,
|
||||
PAUSE_PARSE_CLARIFY,
|
||||
PAUSE_QUERY_QUOTE,
|
||||
)
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def build_state_graph() -> StateGraph:
|
||||
"""
|
||||
构建未编译的状态图。
|
||||
|
||||
节点顺序对齐 prompt/14:解析/补问 → 建单 → 查价 → 协同 → 报价 → 采用/调价 → 成交。
|
||||
骨架为直线边;后续可加条件边,勿在此塞主账逻辑。
|
||||
"""
|
||||
g: StateGraph = StateGraph(InquiryGraphState)
|
||||
g.add_node(PAUSE_PARSE_CLARIFY, graph_nodes.node_parse_clarify)
|
||||
g.add_node(PAUSE_CREATE_ORDER, graph_nodes.node_create_order)
|
||||
g.add_node(PAUSE_QUERY_QUOTE, graph_nodes.node_query_quote)
|
||||
g.add_node(PAUSE_COLLABORATE, graph_nodes.node_collaborate)
|
||||
g.add_node(PAUSE_GENERATE_QUOTE, graph_nodes.node_generate_quote)
|
||||
g.add_node(PAUSE_ADOPT_ADJUST, graph_nodes.node_adopt_adjust)
|
||||
g.add_node(PAUSE_CLOSE_DEAL, graph_nodes.node_close_deal)
|
||||
|
||||
g.add_edge(START, PAUSE_PARSE_CLARIFY)
|
||||
g.add_edge(PAUSE_PARSE_CLARIFY, PAUSE_CREATE_ORDER)
|
||||
g.add_edge(PAUSE_CREATE_ORDER, PAUSE_QUERY_QUOTE)
|
||||
g.add_edge(PAUSE_QUERY_QUOTE, PAUSE_COLLABORATE)
|
||||
g.add_edge(PAUSE_COLLABORATE, PAUSE_GENERATE_QUOTE)
|
||||
g.add_edge(PAUSE_GENERATE_QUOTE, PAUSE_ADOPT_ADJUST)
|
||||
g.add_edge(PAUSE_ADOPT_ADJUST, PAUSE_CLOSE_DEAL)
|
||||
g.add_edge(PAUSE_CLOSE_DEAL, END)
|
||||
return g
|
||||
|
||||
|
||||
def compile_inquiry_graph(*, checkpointer: Any) -> Any:
|
||||
"""
|
||||
编译可执行图。
|
||||
|
||||
参数:LangGraph checkpointer(Postgres 或 Memory)。
|
||||
返回:CompiledGraph;带 interrupt_before。
|
||||
线程:应在 Worker 启动线程调用一次,缓存复用。
|
||||
"""
|
||||
graph = build_state_graph()
|
||||
compiled = graph.compile(
|
||||
checkpointer=checkpointer,
|
||||
interrupt_before=list(INTERRUPT_BEFORE),
|
||||
)
|
||||
logger.info(
|
||||
"询价 Graph 已编译 interrupt_before=%s",
|
||||
list(INTERRUPT_BEFORE),
|
||||
)
|
||||
return compiled
|
||||
@@ -0,0 +1,91 @@
|
||||
"""
|
||||
Checkpoint 工厂:PostgreSQL 优先,开发可降级内存。
|
||||
|
||||
本文件职责:按 CHECKPOINT_BACKEND / PG_DSN 创建 LangGraph checkpointer。
|
||||
副作用:postgres 模式会 setup() 建 checkpoint 表(仅当前 PG_DATABASE)。
|
||||
禁止:把 checkpoint 写入 Redis;禁止指向现网 ytd_runtime。
|
||||
线程:Worker 进程启动时创建一次,供多槽共享(同一 thread_id 写冲突由 runtime 锁)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class CheckpointBundle:
|
||||
"""
|
||||
checkpointer 与可选连接资源。
|
||||
|
||||
postgres 模式下保留 context/cm 以便进程退出时关闭连接池。
|
||||
"""
|
||||
|
||||
checkpointer: Any
|
||||
backend: str
|
||||
_closer: Optional[Any] = None
|
||||
|
||||
def close(self) -> None:
|
||||
"""释放 postgres 连接(若有)。无副作用于业务数据。"""
|
||||
if self._closer is None:
|
||||
return
|
||||
try:
|
||||
# from_conn_string 返回上下文管理器
|
||||
self._closer.__exit__(None, None, None)
|
||||
except Exception: # noqa: BLE001 — 关闭失败只记日志
|
||||
logger.exception("关闭 Postgres checkpointer 失败")
|
||||
self._closer = None
|
||||
|
||||
def create_checkpointer(settings: Settings) -> CheckpointBundle:
|
||||
"""
|
||||
创建 checkpointer。
|
||||
|
||||
- CHECKPOINT_BACKEND=memory:仅本地骨架冒烟,进程重启丢书签。
|
||||
- postgres:需要可连的 resolve_pg_dsn();失败时若 ytd_env=test 可降级 memory 并打警告。
|
||||
"""
|
||||
backend = (settings.checkpoint_backend or "postgres").strip().lower()
|
||||
if backend == "memory":
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
logger.warning("Graph checkpoint 使用 MemorySaver(仅骨架/本机;正式必须 postgres)")
|
||||
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
|
||||
|
||||
dsn = settings.resolve_pg_dsn()
|
||||
if not dsn:
|
||||
if (settings.ytd_env or "").lower() == "prod":
|
||||
raise RuntimeError("正式环境必须配置 PG_DSN 或 PG_HOST/PG_DATABASE/PG_USER")
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
logger.warning(
|
||||
"未配置 PG_DSN,ytd_env=%s 降级 MemorySaver;正式切流前必须接 inquiry_robot_runtime",
|
||||
settings.ytd_env,
|
||||
)
|
||||
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
|
||||
|
||||
try:
|
||||
from langgraph.checkpoint.postgres import PostgresSaver
|
||||
|
||||
# from_conn_string 返回上下文管理器;进入后得到 saver,并需保持打开
|
||||
cm = PostgresSaver.from_conn_string(dsn)
|
||||
saver = cm.__enter__()
|
||||
saver.setup()
|
||||
logger.info(
|
||||
"Graph Postgres checkpointer 就绪 database=%s",
|
||||
settings.pg_database or "(from DSN)",
|
||||
)
|
||||
return CheckpointBundle(checkpointer=saver, backend="postgres", _closer=cm)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if (settings.ytd_env or "").lower() == "prod":
|
||||
raise
|
||||
from langgraph.checkpoint.memory import MemorySaver
|
||||
|
||||
logger.warning(
|
||||
"Postgres checkpointer 初始化失败,降级 MemorySaver:%s",
|
||||
exc,
|
||||
)
|
||||
return CheckpointBundle(checkpointer=MemorySaver(), backend="memory")
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Graph 节点包:一阶段一文件。
|
||||
|
||||
本包只做编排占位;自然语言抽取、主账写、LibreOffice 不在本包实现。
|
||||
"""
|
||||
|
||||
from agent.graph.nodes.adopt_adjust import node_adopt_adjust
|
||||
from agent.graph.nodes.close_deal import node_close_deal
|
||||
from agent.graph.nodes.collaborate import node_collaborate
|
||||
from agent.graph.nodes.create_order import node_create_order
|
||||
from agent.graph.nodes.generate_quote import node_generate_quote
|
||||
from agent.graph.nodes.parse_clarify import node_parse_clarify
|
||||
from agent.graph.nodes.query_quote import node_query_quote
|
||||
|
||||
__all__ = [
|
||||
"node_parse_clarify",
|
||||
"node_create_order",
|
||||
"node_query_quote",
|
||||
"node_collaborate",
|
||||
"node_generate_quote",
|
||||
"node_adopt_adjust",
|
||||
"node_close_deal",
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
节点:采用 / 调价(骨架)。
|
||||
|
||||
正式:卡片动作 + waitVersion 校验后调主账报价版本;本文件不写主账。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_adopt_adjust(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=adopt_adjust。"""
|
||||
logger.info(
|
||||
"graph.node adopt_adjust thread_id=%s(骨架无采用/调价)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "adopt_adjust"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
节点:成交 / 未成交收尾(骨架)。
|
||||
|
||||
正式:调主账闭环;图结束 ≠ 主账已成交。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_close_deal(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=close_deal。"""
|
||||
logger.info(
|
||||
"graph.node close_deal thread_id=%s(骨架无闭环)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "close_deal"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
节点:协同 / 群报价等待(骨架)。
|
||||
|
||||
正式:等群内报价或协同结果;用户文字不得冒充文件完成(wake 另走结构化载荷)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_collaborate(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=collaborate。"""
|
||||
logger.info(
|
||||
"graph.node collaborate thread_id=%s(骨架无协同)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "collaborate"}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
节点:建单准备(骨架)。
|
||||
|
||||
正式职责将是:调用主账建单 API;六态成功只认主账返回,图成功不能冒充。
|
||||
本文件不写 MySQL、不直连 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_create_order(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=create_order。"""
|
||||
logger.info(
|
||||
"graph.node create_order thread_id=%s(骨架无建单)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "create_order"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
节点:报价文件生成等待(骨架)。
|
||||
|
||||
正式:等 Worker LibreOffice/对象存储完成后的结构化 wake;本节点不跑 soffice。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_generate_quote(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=generate_quote。"""
|
||||
logger.info(
|
||||
"graph.node generate_quote thread_id=%s(骨架不转 PDF)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "generate_quote"}
|
||||
@@ -0,0 +1,27 @@
|
||||
"""
|
||||
节点:解析 / 补问阶段(骨架)。
|
||||
|
||||
正式职责将是:触发字段合同检查与补问等待;本文件不抽字段、不调模型。
|
||||
线程:仅在 Worker Graph 槽内执行,禁止占用企微回调线程。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_parse_clarify(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""
|
||||
骨架节点:标记 phase=parse_clarify,不产生业务副作用。
|
||||
|
||||
参数:当前 checkpoint 状态。
|
||||
返回:状态增量(合并进 checkpoint)。
|
||||
"""
|
||||
thread_id = state.get("thread_id") or ""
|
||||
logger.info("graph.node parse_clarify thread_id=%s(骨架无业务)", thread_id)
|
||||
return {"phase": "parse_clarify"}
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
节点:查价 / TMS 结果编排(骨架)。
|
||||
|
||||
正式:经主账查价;分类结果给人选择。禁止在本节点直连 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.graph.state import InquiryGraphState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def node_query_quote(state: InquiryGraphState) -> dict[str, Any]:
|
||||
"""骨架:仅标记 phase=query_quote。"""
|
||||
logger.info(
|
||||
"graph.node query_quote thread_id=%s(骨架无查价)",
|
||||
state.get("thread_id") or "",
|
||||
)
|
||||
return {"phase": "query_quote"}
|
||||
@@ -0,0 +1,29 @@
|
||||
"""
|
||||
Graph 停靠点名称(interrupt_before)。
|
||||
|
||||
本文件职责:集中声明 pause_* 节点名,与 prompt/14「停点中断」对齐。
|
||||
调用时机:builder.compile(interrupt_before=INTERRUPT_BEFORE)。
|
||||
禁止:在停点名上写业务 if/else;业务唤醒校验走 waitVersion(runtime/wake)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
# 节点名 = builder 里 add_node 的名字;interrupt_before 表示「跑该节点前停下等人/等文件」
|
||||
PAUSE_PARSE_CLARIFY = "pause_parse_clarify"
|
||||
PAUSE_CREATE_ORDER = "pause_create_order"
|
||||
PAUSE_QUERY_QUOTE = "pause_query_quote"
|
||||
PAUSE_COLLABORATE = "pause_collaborate"
|
||||
PAUSE_GENERATE_QUOTE = "pause_generate_quote"
|
||||
PAUSE_ADOPT_ADJUST = "pause_adopt_adjust"
|
||||
PAUSE_CLOSE_DEAL = "pause_close_deal"
|
||||
|
||||
# 骨架:全部标为可中断;后续可按业务收窄列表
|
||||
INTERRUPT_BEFORE: tuple[str, ...] = (
|
||||
PAUSE_PARSE_CLARIFY,
|
||||
PAUSE_CREATE_ORDER,
|
||||
PAUSE_QUERY_QUOTE,
|
||||
PAUSE_COLLABORATE,
|
||||
PAUSE_GENERATE_QUOTE,
|
||||
PAUSE_ADOPT_ADJUST,
|
||||
PAUSE_CLOSE_DEAL,
|
||||
)
|
||||
@@ -0,0 +1,197 @@
|
||||
"""
|
||||
Graph 运行时公开 API:start / resume / wake。
|
||||
|
||||
本文件职责:
|
||||
- Worker 进程内持有已编译图;
|
||||
- 按 thread_id 启停;无 sender_id 拒绝进图;
|
||||
- wake 只接受结构化 WorkerWakePayload。
|
||||
|
||||
禁止:在 HTTP 回调线程调用 invoke/resume(会卡企微);同一 thread_id 写冲突用细粒度锁。
|
||||
骨架:节点无业务,invoke 会在第一个 interrupt_before 停下。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.graph.builder import compile_inquiry_graph
|
||||
from agent.graph.checkpoint import CheckpointBundle, create_checkpointer
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphIdentityError(ValueError):
|
||||
"""无 sender_id 或其他身份不满足时抛出。"""
|
||||
|
||||
|
||||
class GraphWakeError(ValueError):
|
||||
"""结构化 wake 校验失败。"""
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphRuntime:
|
||||
"""
|
||||
进程级 Graph 句柄。
|
||||
|
||||
生命周期:Worker 启动 create(),停止 close()。
|
||||
并发:不同 thread_id 可并行;同一 thread_id 串行(_thread_locks)。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
compiled: Any
|
||||
checkpoint: CheckpointBundle
|
||||
_thread_locks: dict[str, threading.Lock] = field(default_factory=dict)
|
||||
_locks_guard: threading.Lock = field(default_factory=threading.Lock)
|
||||
|
||||
@classmethod
|
||||
def create(cls, settings: Optional[Settings] = None) -> "GraphRuntime":
|
||||
"""
|
||||
编译图并挂 checkpointer。
|
||||
|
||||
副作用:可能连接 PG 并 setup checkpoint 表;失败策略见 create_checkpointer。
|
||||
"""
|
||||
cfg = settings or get_settings()
|
||||
bundle = create_checkpointer(cfg)
|
||||
compiled = compile_inquiry_graph(checkpointer=bundle.checkpointer)
|
||||
logger.info(
|
||||
"GraphRuntime 就绪 checkpoint_backend=%s ytd_env=%s",
|
||||
bundle.backend,
|
||||
cfg.ytd_env,
|
||||
)
|
||||
return cls(settings=cfg, compiled=compiled, checkpoint=bundle)
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭 checkpointer 连接。"""
|
||||
self.checkpoint.close()
|
||||
|
||||
def _lock_for(self, thread_id: str) -> threading.Lock:
|
||||
"""取得同一 thread_id 的互斥锁(禁止全局大锁串行所有工单)。"""
|
||||
with self._locks_guard:
|
||||
lock = self._thread_locks.get(thread_id)
|
||||
if lock is None:
|
||||
lock = threading.Lock()
|
||||
self._thread_locks[thread_id] = lock
|
||||
return lock
|
||||
|
||||
@staticmethod
|
||||
def _require_sender_id(sender_id: str) -> str:
|
||||
sid = (sender_id or "").strip()
|
||||
if not sid:
|
||||
raise GraphIdentityError("无 sender_id,拒绝进入 Graph(prompt/09、14)")
|
||||
return sid
|
||||
|
||||
def start_thread(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
thread_id: Optional[str] = None,
|
||||
inquiry_no: Optional[str] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
新开一条询价图(新 thread_id)。
|
||||
|
||||
调用线程:必须是 Worker HTTP 类槽,禁止企微回调线程。
|
||||
副作用:写入 checkpoint;骨架会在首个 pause 中断。
|
||||
返回:含 thread_id 与 invoke 原始结果摘要。
|
||||
"""
|
||||
sid = self._require_sender_id(sender_id)
|
||||
tid = (thread_id or "").strip() or str(uuid.uuid4())
|
||||
config = {"configurable": {"thread_id": tid}}
|
||||
initial = {
|
||||
"thread_id": tid,
|
||||
"sender_id": sid,
|
||||
"inquiry_no": inquiry_no,
|
||||
"wait_version": 0,
|
||||
"phase": "start",
|
||||
}
|
||||
lock = self._lock_for(tid)
|
||||
with lock:
|
||||
# 骨架:invoke 至 interrupt_before 第一个停点
|
||||
result = self.compiled.invoke(initial, config=config)
|
||||
logger.info("graph.start_thread thread_id=%s sender_id=%s", tid, sid)
|
||||
return {"thread_id": tid, "state": result, "interrupted": True}
|
||||
|
||||
def resume_thread(
|
||||
self,
|
||||
*,
|
||||
thread_id: str,
|
||||
sender_id: str,
|
||||
values: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
从精确断点继续(入站「继续本单」路径的图侧入口)。
|
||||
|
||||
无业务路由:values 仅合并进状态;真正 Handler 后续再接。
|
||||
"""
|
||||
sid = self._require_sender_id(sender_id)
|
||||
tid = (thread_id or "").strip()
|
||||
if not tid:
|
||||
raise GraphIdentityError("resume 缺少 thread_id")
|
||||
config = {"configurable": {"thread_id": tid}}
|
||||
lock = self._lock_for(tid)
|
||||
with lock:
|
||||
patch = dict(values or {})
|
||||
patch["sender_id"] = sid
|
||||
patch["thread_id"] = tid
|
||||
# None 表示从 checkpoint 继续;有 patch 时先 update 再跑
|
||||
if patch:
|
||||
self.compiled.update_state(config, patch)
|
||||
result = self.compiled.invoke(None, config=config)
|
||||
logger.info("graph.resume_thread thread_id=%s", tid)
|
||||
return {"thread_id": tid, "state": result}
|
||||
|
||||
def wake_from_worker(self, payload: WorkerWakePayload) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 结构化唤醒(识别/PDF 完成)。
|
||||
|
||||
拒绝:缺 inquiry_no/quoteVersion/waitVersion;用户纯文字不得走本 API。
|
||||
"""
|
||||
try:
|
||||
patch = payload.as_state_patch()
|
||||
except ValueError as exc:
|
||||
raise GraphWakeError(str(exc)) from exc
|
||||
tid = payload.thread_id.strip()
|
||||
config = {"configurable": {"thread_id": tid}}
|
||||
lock = self._lock_for(tid)
|
||||
with lock:
|
||||
self.compiled.update_state(config, patch)
|
||||
result = self.compiled.invoke(None, config=config)
|
||||
logger.info(
|
||||
"graph.wake_from_worker thread_id=%s kind=%s wait_version=%s",
|
||||
tid,
|
||||
payload.kind,
|
||||
payload.wait_version,
|
||||
)
|
||||
return {"thread_id": tid, "state": result, "wake": patch["last_wake"]}
|
||||
|
||||
|
||||
_runtime_singleton: Optional[GraphRuntime] = None
|
||||
_runtime_guard = threading.Lock()
|
||||
|
||||
|
||||
def get_graph_runtime(settings: Optional[Settings] = None) -> GraphRuntime:
|
||||
"""
|
||||
进程内单例 GraphRuntime(懒创建)。
|
||||
|
||||
供 Worker 与后续 jobs 槽使用;HTTP 进程不应依赖本单例去 invoke。
|
||||
"""
|
||||
global _runtime_singleton
|
||||
with _runtime_guard:
|
||||
if _runtime_singleton is None:
|
||||
_runtime_singleton = GraphRuntime.create(settings)
|
||||
return _runtime_singleton
|
||||
|
||||
|
||||
def shutdown_graph_runtime() -> None:
|
||||
"""进程退出时关闭单例。"""
|
||||
global _runtime_singleton
|
||||
with _runtime_guard:
|
||||
if _runtime_singleton is not None:
|
||||
_runtime_singleton.close()
|
||||
_runtime_singleton = None
|
||||
@@ -0,0 +1,68 @@
|
||||
"""
|
||||
Graph 骨架自检:编译 + start_thread(Memory)+ 无 sender_id 拒绝。
|
||||
|
||||
用法(在 inquiry-agent 目录、已装依赖):
|
||||
set CHECKPOINT_BACKEND=memory
|
||||
python -m agent.graph.selftest
|
||||
|
||||
不连企微、不连主账;正式验收仍以真实企微事件为准。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
# 自检强制内存,避免误连生产 PG
|
||||
os.environ["CHECKPOINT_BACKEND"] = "memory"
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
|
||||
from agent.config import get_settings
|
||||
from agent.graph.runtime import GraphIdentityError, GraphRuntime, shutdown_graph_runtime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
|
||||
get_settings.cache_clear()
|
||||
rt = GraphRuntime.create(get_settings())
|
||||
try:
|
||||
try:
|
||||
rt.start_thread(sender_id="")
|
||||
logger.error("预期应拒绝空 sender_id")
|
||||
return 1
|
||||
except GraphIdentityError:
|
||||
logger.info("OK: 空 sender_id 已拒绝")
|
||||
|
||||
started = rt.start_thread(sender_id="selftest_user")
|
||||
tid = started["thread_id"]
|
||||
logger.info("OK: start_thread thread_id=%s", tid)
|
||||
|
||||
# 骨架:wake 需要已有 thread;写入 last_wake 后再 resume 一步
|
||||
wake = WorkerWakePayload(
|
||||
thread_id=tid,
|
||||
inquiry_no="SELFTEST-001",
|
||||
quote_version=1,
|
||||
wait_version=0,
|
||||
kind="recognition_done",
|
||||
)
|
||||
woken = rt.wake_from_worker(wake)
|
||||
logger.info("OK: wake_from_worker kind=%s", woken["wake"]["kind"])
|
||||
|
||||
resumed = rt.resume_thread(thread_id=tid, sender_id="selftest_user")
|
||||
logger.info("OK: resume_thread thread_id=%s", resumed["thread_id"])
|
||||
logger.info(
|
||||
"Graph 骨架自检通过 checkpoint_backend=%s",
|
||||
rt.checkpoint.backend,
|
||||
)
|
||||
return 0
|
||||
finally:
|
||||
rt.close()
|
||||
shutdown_graph_runtime()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
询价 Graph 状态(骨架)。
|
||||
|
||||
本文件职责:定义一条询价在图内携带的最小字段;checkpoint 会持久化本结构。
|
||||
禁止:把主账六态真相写进本状态并当作已成功;禁止在本文件写业务分支。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional, TypedDict
|
||||
|
||||
|
||||
class InquiryGraphState(TypedDict, total=False):
|
||||
"""
|
||||
一单一份图状态。
|
||||
|
||||
thread_id:LangGraph / checkpoint 主键(一询价一 thread)。
|
||||
sender_id:入站企微 userid;为空时 runtime 拒绝进图。
|
||||
wait_version:与补问卡/采用卡绑定;错版本点击应丢弃(业务层后续实现)。
|
||||
last_wake:最近一次 Worker 结构化唤醒快照(仅记录,不替代主账)。
|
||||
"""
|
||||
|
||||
thread_id: str
|
||||
sender_id: str
|
||||
inquiry_no: Optional[str]
|
||||
wait_version: int
|
||||
quote_version: Optional[int]
|
||||
# 骨架占位:后续由各节点写入阶段标记;不得冒充主账六态
|
||||
phase: str
|
||||
last_wake: dict[str, Any]
|
||||
# 调试备注,正式路径勿依赖
|
||||
notes: str
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
Worker 结构化唤醒载荷。
|
||||
|
||||
本文件职责:定义「识别/PDF 完成」唤醒图的数据结构;禁止用用户聊天文字冒充文件完成。
|
||||
调用方:jobs 槽完成后 → GraphRuntime.wake_from_worker。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class WorkerWakePayload:
|
||||
"""
|
||||
结构化 wake(对齐 prompt/14)。
|
||||
|
||||
inquiry_no + quote_version + wait_version 三者用于对齐停点;缺任一则 runtime 拒绝。
|
||||
kind:识别完成 / PDF 完成等枚举字符串,骨架只做透传校验。
|
||||
"""
|
||||
|
||||
thread_id: str
|
||||
inquiry_no: str
|
||||
quote_version: int
|
||||
wait_version: int
|
||||
kind: str
|
||||
detail: Optional[dict[str, Any]] = None
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
校验必填字段。
|
||||
|
||||
副作用:无效时抛 ValueError;不写库。
|
||||
"""
|
||||
if not (self.thread_id or "").strip():
|
||||
raise ValueError("wake 缺少 thread_id")
|
||||
if not (self.inquiry_no or "").strip():
|
||||
raise ValueError("wake 缺少 inquiry_no")
|
||||
if self.quote_version is None or int(self.quote_version) < 0:
|
||||
raise ValueError("wake quote_version 无效")
|
||||
if self.wait_version is None or int(self.wait_version) < 0:
|
||||
raise ValueError("wake wait_version 无效")
|
||||
if not (self.kind or "").strip():
|
||||
raise ValueError("wake 缺少 kind")
|
||||
|
||||
def as_state_patch(self) -> dict[str, Any]:
|
||||
"""转为写入图状态的增量(骨架:只记录 last_wake,不推进六态)。"""
|
||||
self.validate()
|
||||
return {
|
||||
"inquiry_no": self.inquiry_no,
|
||||
"quote_version": int(self.quote_version),
|
||||
"wait_version": int(self.wait_version),
|
||||
"last_wake": {
|
||||
"kind": self.kind,
|
||||
"inquiry_no": self.inquiry_no,
|
||||
"quote_version": int(self.quote_version),
|
||||
"wait_version": int(self.wait_version),
|
||||
"detail": self.detail or {},
|
||||
},
|
||||
}
|
||||
@@ -2,5 +2,9 @@
|
||||
Handler 包:一类意图 / 一类卡片一个模块。
|
||||
|
||||
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
|
||||
骨架:仅包占位。
|
||||
当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。
|
||||
"""
|
||||
|
||||
from agent.handlers.echo_text import handle_echo_text
|
||||
|
||||
__all__ = ["handle_echo_text"]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
意图:异常上报(骨架)。
|
||||
|
||||
正式:写主账异常时间线;不在此直连 TMS。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_abnormal(*, sender_id: str, inquiry_no: str, reason: str = "") -> None:
|
||||
"""骨架:仅记日志。"""
|
||||
if not sender_id:
|
||||
return
|
||||
logger.info(
|
||||
"handler.abnormal stub sender=%s inquiry=%s reason=%s",
|
||||
sender_id,
|
||||
inquiry_no,
|
||||
reason or "-",
|
||||
)
|
||||
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
意图:模板卡 / 补问卡动作(骨架)。
|
||||
|
||||
正式:校验 clarification token 绑定的 waitVersion + 允许动作集合。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_card_action(
|
||||
*,
|
||||
sender_id: str,
|
||||
thread_id: str,
|
||||
wait_version: int,
|
||||
action: str,
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""骨架:仅记日志;无 sender_id 直接返回。"""
|
||||
if not (sender_id or "").strip():
|
||||
return
|
||||
logger.info(
|
||||
"handler.card_action stub sender=%s thread=%s wait=%s action=%s",
|
||||
sender_id,
|
||||
thread_id,
|
||||
wait_version,
|
||||
action,
|
||||
)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
意图:成交 / 未成交(骨架)。
|
||||
|
||||
正式:调主账闭环 API,带幂等键;图成功 ≠ 主账成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_close_deal(
|
||||
*,
|
||||
sender_id: str,
|
||||
inquiry_no: str,
|
||||
outcome: str,
|
||||
) -> None:
|
||||
"""
|
||||
outcome:won | lost 等;骨架不写主账。
|
||||
"""
|
||||
if not sender_id or not inquiry_no:
|
||||
return
|
||||
logger.info(
|
||||
"handler.close_deal stub sender=%s inquiry=%s outcome=%s",
|
||||
sender_id,
|
||||
inquiry_no,
|
||||
outcome,
|
||||
)
|
||||
@@ -0,0 +1,24 @@
|
||||
"""
|
||||
意图:继续本单 / Graph resume(骨架)。
|
||||
|
||||
正式:校验 thread_id + waitVersion 后 Worker 槽 resume;禁止在 HTTP 回调线程 invoke。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_continue_thread(message: InboundMessage, *, thread_id: str = "") -> None:
|
||||
"""骨架:仅记日志。"""
|
||||
if not message.has_sender():
|
||||
return
|
||||
logger.info(
|
||||
"handler.continue_thread stub sender=%s thread_id=%s",
|
||||
message.sender_id,
|
||||
thread_id or "-",
|
||||
)
|
||||
@@ -9,8 +9,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from agent.channel.queue import MemoryMessageStore
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -18,7 +18,7 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def handle_echo_text(
|
||||
message: InboundMessage,
|
||||
store: MemoryMessageStore,
|
||||
store: Any,
|
||||
*,
|
||||
ytd_env: str,
|
||||
reply_template: str,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
意图:新开询价(骨架)。
|
||||
|
||||
正式:校验字段合同 → 主账建单准备 → Graph start_thread。
|
||||
本文件不调模型、不写六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from agent.channel.wecom.models import InboundMessage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def handle_new_inquiry(message: InboundMessage) -> None:
|
||||
"""骨架:仅记日志。须已有 sender_id。"""
|
||||
if not message.has_sender():
|
||||
logger.warning("new_inquiry 拒绝:无 sender_id")
|
||||
return
|
||||
logger.info(
|
||||
"handler.new_inquiry stub sender=%s msg_id=%s",
|
||||
message.sender_id,
|
||||
message.message_id,
|
||||
)
|
||||
@@ -13,6 +13,7 @@ from contextlib import asynccontextmanager
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from agent.channel.aibot import router as aibot_router
|
||||
from agent.channel.bridge import router as bridge_router
|
||||
from agent.channel.h5 import router as h5_router
|
||||
from agent.channel.runtime import start_channel_runtime
|
||||
@@ -71,6 +72,7 @@ def create_app() -> FastAPI:
|
||||
app.include_router(wecom_router)
|
||||
app.include_router(h5_router)
|
||||
app.include_router(bridge_router)
|
||||
app.include_router(aibot_router)
|
||||
return app
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
"""
|
||||
任务槽与认领循环:识别、LibreOffice、Archive 等。
|
||||
任务槽与认领循环:识别、LibreOffice、Archive、wake、LLM Stream。
|
||||
|
||||
本包约束(硬):LibreOffice 仅 1 槽;HTTP 类 2~3 槽;inbox/outbox 不得与 soffice 串同一阻塞线。
|
||||
禁止:一把大锁串行所有工单;禁止 wecom:worker:singleton。
|
||||
@@ -11,7 +11,18 @@ import logging
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
from typing import Callable, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.jobs.archive import archive_stream, process_archive_stub
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter
|
||||
from agent.jobs.pdf_job import pdf_stream, process_pdf_job
|
||||
from agent.jobs.recognition import process_recognition_stub
|
||||
from agent.jobs.wake_consumer import claim_and_apply_wake, wake_stream
|
||||
from agent.llm import LlmGateway, LlmMode
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.routing import DeepSeekARouter
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -22,7 +33,6 @@ class SlotPool:
|
||||
进程内有限槽线程池骨架。
|
||||
|
||||
用途:限制同类慢活并发(尤其 LibreOffice=1)。
|
||||
为何不用进程级 Redis 单例:同机只应起一份 Worker 服务;防重入靠任务认领。
|
||||
"""
|
||||
|
||||
name: str
|
||||
@@ -35,11 +45,7 @@ class SlotPool:
|
||||
self._sem = threading.BoundedSemaphore(self.size)
|
||||
|
||||
def run(self, fn: Callable[[], None], *, label: str = "") -> None:
|
||||
"""
|
||||
占用一个槽执行 fn;槽满则阻塞等待(调用方应在 Worker 线程,勿在 HTTP 回调线程调用)。
|
||||
|
||||
副作用:执行 fn;不写库(骨架)。
|
||||
"""
|
||||
"""占用一个槽执行 fn;勿在 HTTP 回调线程调用。"""
|
||||
acquired = self._sem.acquire(timeout=30)
|
||||
if not acquired:
|
||||
logger.warning("槽超时未获取 name=%s label=%s", self.name, label)
|
||||
@@ -53,20 +59,33 @@ class SlotPool:
|
||||
@dataclass
|
||||
class WorkerRuntime:
|
||||
"""
|
||||
Worker 运行时:持有分槽与 inbox/outbox 占位线程。
|
||||
Worker 运行时:分槽 + wake / LLM / 识别 / 存档 / LibreOffice 循环。
|
||||
|
||||
启动时机:worker_app main;停止:进程退出或 stop()。
|
||||
禁止:把所有任务丢进 LibreOffice 槽。
|
||||
启动时机:worker_app main。
|
||||
"""
|
||||
|
||||
libreoffice: SlotPool
|
||||
http_jobs: SlotPool
|
||||
settings: Settings
|
||||
graph: Optional[GraphRuntime] = None
|
||||
redis: Optional[RedisRuntime] = None
|
||||
llm: Optional[LlmGateway] = None
|
||||
_stop: threading.Event = field(default_factory=threading.Event)
|
||||
_threads: list[threading.Thread] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, libreoffice_slots: int, http_job_slots: int) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1,防止误配双 soffice。"""
|
||||
def from_settings(
|
||||
cls,
|
||||
libreoffice_slots: int,
|
||||
http_job_slots: int,
|
||||
*,
|
||||
graph: Optional[GraphRuntime] = None,
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
llm: Optional[LlmGateway] = None,
|
||||
settings: Optional[Settings] = None,
|
||||
) -> "WorkerRuntime":
|
||||
"""按配置创建槽;强制 LibreOffice 槽为 1。"""
|
||||
cfg = settings or get_settings()
|
||||
lo = 1 if libreoffice_slots != 1 else libreoffice_slots
|
||||
if libreoffice_slots != 1:
|
||||
logger.warning(
|
||||
@@ -76,47 +95,178 @@ class WorkerRuntime:
|
||||
return cls(
|
||||
libreoffice=SlotPool("libreoffice", lo),
|
||||
http_jobs=SlotPool("http_jobs", max(2, http_job_slots)),
|
||||
settings=cfg,
|
||||
graph=graph,
|
||||
redis=redis,
|
||||
llm=llm,
|
||||
)
|
||||
|
||||
def start(self) -> None:
|
||||
"""
|
||||
启动 inbox / outbox / 空转巡检线程(骨架:只心跳日志,不消费真实队列)。
|
||||
|
||||
副作用:创建守护线程;不连 Redis(骨架)。
|
||||
"""
|
||||
"""启动分槽消费循环。"""
|
||||
self._stop.clear()
|
||||
self._threads = [
|
||||
threading.Thread(target=self._inbox_loop, name="inbox-loop", daemon=True),
|
||||
threading.Thread(target=self._outbox_loop, name="outbox-loop", daemon=True),
|
||||
threading.Thread(target=self._http_slot_idle, name="http-slots-idle", daemon=True),
|
||||
threading.Thread(target=self._wake_loop, name="wake-loop", daemon=True),
|
||||
threading.Thread(target=self._llm_loop, name="llm-loop", daemon=True),
|
||||
threading.Thread(target=self._recognition_loop, name="recog-loop", daemon=True),
|
||||
threading.Thread(target=self._archive_loop, name="archive-loop", daemon=True),
|
||||
threading.Thread(target=self._pdf_loop, name="pdf-loop", daemon=True),
|
||||
]
|
||||
for t in self._threads:
|
||||
t.start()
|
||||
lo = LibreOfficeConverter.from_settings(self.settings)
|
||||
logger.info(
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s",
|
||||
"WorkerRuntime 已启动 libreoffice_slots=%s http_job_slots=%s soffice=%s",
|
||||
self.libreoffice.size,
|
||||
self.http_jobs.size,
|
||||
"ok" if lo.available() else "missing",
|
||||
)
|
||||
|
||||
def stop(self) -> None:
|
||||
"""请求停止占位循环;不保证立刻打断正在跑的槽任务(骨架无真实任务)。"""
|
||||
self._stop.set()
|
||||
|
||||
def _inbox_loop(self) -> None:
|
||||
"""inbox 独立线程:后续从 PG/队列认领入站;禁止与 soffice 共用阻塞队列。"""
|
||||
def _wake_loop(self) -> None:
|
||||
"""HTTP 类槽消费结构化 wake → Graph。"""
|
||||
if not self.redis or not self.graph:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
q = wake_stream(self.redis.client)
|
||||
consumer = f"wake-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _outbox_loop(self) -> None:
|
||||
"""outbox 独立线程:后续发送企微;失败重试;过期丢弃不计业务成功。"""
|
||||
def _job() -> None:
|
||||
claim_and_apply_wake(queue=q, graph=self.graph, consumer=consumer)
|
||||
|
||||
self.http_jobs.run(_job, label="wake")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _llm_loop(self) -> None:
|
||||
"""
|
||||
HTTP 类槽消费 LLM Stream。
|
||||
|
||||
ordered_actions:走 DeepSeekARouter(默认 stub;LLM_ALLOW_NETWORK 才可能真网)。
|
||||
其它 mode:骨架 stub。
|
||||
"""
|
||||
if not self.redis or not self.llm:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.llm_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"llm-{threading.get_ident()}"
|
||||
router = DeepSeekARouter.from_settings(
|
||||
self.settings,
|
||||
allow_network=bool(self.settings.llm_allow_network),
|
||||
)
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
|
||||
def _http_slot_idle(self) -> None:
|
||||
"""证明 HTTP 类槽可并行占用;骨架不跑真实 LLM。"""
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
mode = str(data.get("mode") or "ordered_actions")
|
||||
payload = dict(data.get("payload") or {})
|
||||
try:
|
||||
if mode == LlmMode.ORDERED_ACTIONS.value:
|
||||
decision = router.route(
|
||||
text=str(payload.get("text") or ""),
|
||||
sender_id=str(payload.get("sender_id") or ""),
|
||||
)
|
||||
logger.info(
|
||||
"llm 槽 A 路由 id=%s intent=%s",
|
||||
entry_id,
|
||||
decision.intent,
|
||||
)
|
||||
else:
|
||||
result = self.llm.invoke_stub(mode=mode, payload=payload)
|
||||
logger.info(
|
||||
"llm 槽处理 id=%s mode=%s stub=%s",
|
||||
entry_id,
|
||||
mode,
|
||||
result.get("stub"),
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="llm")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _recognition_loop(self) -> None:
|
||||
"""HTTP 类槽消费识别 Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = self.redis.recognition_stream
|
||||
stream.ensure_group()
|
||||
consumer = f"recog-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
def _noop() -> None:
|
||||
time.sleep(0.05)
|
||||
|
||||
self.http_jobs.run(_noop, label="idle-ping")
|
||||
time.sleep(5.0)
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_recognition_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="recognition")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _archive_loop(self) -> None:
|
||||
"""HTTP 类槽消费存档 Stream(与 soffice 隔离)。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = archive_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
consumer = f"arch-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
process_archive_stub(data)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.http_jobs.run(_job, label="archive")
|
||||
time.sleep(0.2)
|
||||
|
||||
def _pdf_loop(self) -> None:
|
||||
"""仅 LibreOffice 单槽消费 PDF Stream。"""
|
||||
if not self.redis:
|
||||
while not self._stop.is_set():
|
||||
time.sleep(2.0)
|
||||
return
|
||||
stream = pdf_stream(self.redis)
|
||||
stream.ensure_group()
|
||||
converter = LibreOfficeConverter.from_settings(self.settings)
|
||||
consumer = f"pdf-{threading.get_ident()}"
|
||||
while not self._stop.is_set():
|
||||
|
||||
def _job() -> None:
|
||||
claimed = stream.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
result = process_pdf_job(data, converter=converter)
|
||||
logger.info(
|
||||
"pdf 槽 id=%s ok=%s err=%s",
|
||||
entry_id,
|
||||
result.ok,
|
||||
result.error or "-",
|
||||
)
|
||||
finally:
|
||||
stream.ack(entry_id)
|
||||
|
||||
self.libreoffice.run(_job, label="pdf")
|
||||
time.sleep(0.3)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
"""
|
||||
微盛存档任务壳:独立 Stream,与 LibreOffice 槽隔离。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式下载与登记后续接对象存储 + 主账附件元数据。
|
||||
禁止:与 soffice 共用阻塞线;禁止把存档成功冒充业务成交成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_ARCHIVE
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArchiveEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def archive_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""存档 Stream(懒创建消费组由调用方 ensure)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_ARCHIVE)
|
||||
|
||||
|
||||
def enqueue_archive(
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> ArchiveEnqueueResult:
|
||||
"""存档任务入队(快回)。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = archive_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="archive",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return ArchiveEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("archive.enqueue id=%s", entry_id)
|
||||
return ArchiveEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_archive_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""存档占位:不访问微盛、不写对象存储。"""
|
||||
logger.info("archive.stub keys=%s", sorted(data.keys()))
|
||||
return {"ok": True, "stub": True, "note": "存档壳:未下载未登记"}
|
||||
@@ -0,0 +1,146 @@
|
||||
"""
|
||||
死信壳:失败任务落库 / 内存,不含自动重放业务。
|
||||
|
||||
本文件职责:enqueue_dead_letter;正式重放由运维/后续模块显式触发。
|
||||
禁止:把死信成功当成业务成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DeadLetterRecord:
|
||||
id: str
|
||||
source: str
|
||||
source_id: str = ""
|
||||
job_type: str = ""
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
error: str = ""
|
||||
attempts: int = 0
|
||||
|
||||
|
||||
class MemoryDeadLetterStore:
|
||||
"""内存死信壳。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rows: list[DeadLetterRecord] = []
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
with self._lock:
|
||||
if not record.id:
|
||||
record.id = str(uuid.uuid4())
|
||||
self._rows.append(record)
|
||||
logger.info(
|
||||
"dead_letter.memory source=%s type=%s id=%s",
|
||||
record.source,
|
||||
record.job_type,
|
||||
record.id,
|
||||
)
|
||||
return record.id
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with self._lock:
|
||||
return list(reversed(self._rows[-limit:]))
|
||||
|
||||
|
||||
class PostgresDeadLetterStore:
|
||||
"""PG agent_dead_letter 壳(须 002 迁移)。"""
|
||||
|
||||
def __init__(self, dsn: str) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
|
||||
def enqueue(self, record: DeadLetterRecord) -> str:
|
||||
did = record.id or str(uuid.uuid4())
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_dead_letter (
|
||||
id, source, source_id, job_type, payload, error, attempts
|
||||
) VALUES (%s, %s, %s, %s, %s::jsonb, %s, %s)
|
||||
""",
|
||||
(
|
||||
did,
|
||||
record.source,
|
||||
record.source_id,
|
||||
record.job_type,
|
||||
json.dumps(record.payload, ensure_ascii=False),
|
||||
record.error,
|
||||
record.attempts,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("dead_letter.pg id=%s source=%s", did, record.source)
|
||||
return did
|
||||
|
||||
def list_recent(self, limit: int = 20) -> list[DeadLetterRecord]:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT id, source, source_id, job_type, payload, error, attempts
|
||||
FROM agent_dead_letter
|
||||
ORDER BY created_at DESC
|
||||
LIMIT %s
|
||||
""",
|
||||
(limit,),
|
||||
).fetchall()
|
||||
return [
|
||||
DeadLetterRecord(
|
||||
id=str(r[0]),
|
||||
source=str(r[1]),
|
||||
source_id=str(r[2] or ""),
|
||||
job_type=str(r[3] or ""),
|
||||
payload=dict(r[4] or {}),
|
||||
error=str(r[5] or ""),
|
||||
attempts=int(r[6] or 0),
|
||||
)
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
def create_dead_letter_store(*, dsn: str = "", backend: str = "auto"):
|
||||
"""工厂。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryDeadLetterStore()
|
||||
return PostgresDeadLetterStore(dsn)
|
||||
|
||||
|
||||
def enqueue_dead_letter(
|
||||
*,
|
||||
source: str,
|
||||
error: str,
|
||||
job_type: str = "",
|
||||
source_id: str = "",
|
||||
payload: Optional[dict[str, Any]] = None,
|
||||
attempts: int = 0,
|
||||
store=None,
|
||||
) -> str:
|
||||
"""
|
||||
统一入死信入口(框架)。
|
||||
|
||||
store 为空则用内存临时店(调用方应注入进程级单例)。
|
||||
"""
|
||||
st = store or MemoryDeadLetterStore()
|
||||
return st.enqueue(
|
||||
DeadLetterRecord(
|
||||
id=str(uuid.uuid4()),
|
||||
source=source,
|
||||
source_id=source_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload or {}),
|
||||
error=error,
|
||||
attempts=attempts,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
LibreOffice Excel→PDF 转换壳。
|
||||
|
||||
本文件职责:探测 soffice、在「调用方已占用 LibreOffice 单槽」的前提下同步转 PDF。
|
||||
禁止:在企微回调 / H5 线程调用;禁止本进程同时起两台 soffice(由 SlotPool size=1 保证)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeResult:
|
||||
"""转换结果;ok=False 时 error 说明原因,不抛到 Worker 主循环。"""
|
||||
|
||||
ok: bool
|
||||
pdf_path: str = ""
|
||||
error: str = ""
|
||||
returncode: int = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class LibreOfficeConverter:
|
||||
"""
|
||||
soffice 无界面转换器。
|
||||
|
||||
调用约定:仅从 WorkerRuntime.libreoffice 槽内进入 convert()。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LibreOfficeConverter":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def executable(self) -> Path:
|
||||
return Path(self.settings.libreoffice_path)
|
||||
|
||||
def available(self) -> bool:
|
||||
"""本机是否存在配置的 soffice 可执行文件。"""
|
||||
return self.executable().is_file()
|
||||
|
||||
def convert_xlsx_to_pdf(self, xlsx_path: str | Path) -> LibreOfficeResult:
|
||||
"""
|
||||
将 Excel 转为同目录 PDF。
|
||||
|
||||
副作用:启动 soffice 子进程(超时见 settings);成功写 PDF 文件。
|
||||
线程:必须已在 LibreOffice 单槽内。
|
||||
"""
|
||||
source = Path(xlsx_path).resolve()
|
||||
if not source.is_file():
|
||||
return LibreOfficeResult(ok=False, error="source_not_found")
|
||||
exe = self.executable()
|
||||
if not exe.is_file():
|
||||
return LibreOfficeResult(ok=False, error="libreoffice_not_installed")
|
||||
out_dir = source.parent
|
||||
target = source.with_suffix(".pdf")
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
[
|
||||
str(exe),
|
||||
"--headless",
|
||||
"--convert-to",
|
||||
"pdf",
|
||||
"--outdir",
|
||||
str(out_dir),
|
||||
str(source),
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=float(self.settings.libreoffice_timeout_seconds),
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
logger.warning("LibreOffice 执行失败: %s", exc)
|
||||
return LibreOfficeResult(ok=False, error=f"exec_failed:{exc}")
|
||||
if completed.returncode != 0 or not target.is_file():
|
||||
stderr = (completed.stderr or b"")[:400]
|
||||
logger.warning(
|
||||
"LibreOffice 未生成 PDF rc=%s stderr=%s",
|
||||
completed.returncode,
|
||||
stderr,
|
||||
)
|
||||
return LibreOfficeResult(
|
||||
ok=False,
|
||||
error="pdf_not_generated",
|
||||
returncode=int(completed.returncode),
|
||||
)
|
||||
head = target.read_bytes()[:5]
|
||||
if head != b"%PDF-":
|
||||
target.unlink(missing_ok=True)
|
||||
return LibreOfficeResult(ok=False, error="pdf_header_invalid")
|
||||
logger.info("LibreOffice 转 PDF 成功 path=%s", target)
|
||||
return LibreOfficeResult(ok=True, pdf_path=str(target), returncode=0)
|
||||
@@ -0,0 +1,80 @@
|
||||
"""
|
||||
报价 PDF 任务壳:入队后由 LibreOffice **单槽**消费。
|
||||
|
||||
本文件职责:enqueue_pdf_job / process 调用 LibreOfficeConverter。
|
||||
禁止:在 http_jobs 槽跑 soffice;禁止回调线程转换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.jobs.libreoffice import LibreOfficeConverter, LibreOfficeResult
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.keys import STREAM_PDF
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PdfEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def pdf_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
rt = redis or get_redis_runtime()
|
||||
return StreamQueue(client=rt.client, stream_suffix=STREAM_PDF)
|
||||
|
||||
|
||||
def enqueue_pdf_job(
|
||||
*,
|
||||
xlsx_path: str,
|
||||
inquiry_no: str = "",
|
||||
quote_version: int = 0,
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> PdfEnqueueResult:
|
||||
"""Excel→PDF 任务入队;真正转换只在 LibreOffice 槽。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
q = pdf_stream(rt)
|
||||
q.ensure_group()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="pdf",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return PdfEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
payload = {
|
||||
"xlsx_path": xlsx_path,
|
||||
"inquiry_no": inquiry_no,
|
||||
"quote_version": quote_version,
|
||||
}
|
||||
entry_id = q.enqueue(payload=payload, idempotency_key=idempotency_key)
|
||||
logger.info("pdf.enqueue id=%s inquiry=%s", entry_id, inquiry_no or "-")
|
||||
return PdfEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_pdf_job(
|
||||
data: dict[str, Any],
|
||||
*,
|
||||
converter: Optional[LibreOfficeConverter] = None,
|
||||
) -> LibreOfficeResult:
|
||||
"""
|
||||
处理一条 PDF 任务(调用方必须已占用 libreoffice 槽)。
|
||||
|
||||
缺 xlsx_path 或本机无 soffice 时返回 ok=False,不抛崩 Worker。
|
||||
"""
|
||||
path = str(data.get("xlsx_path") or "")
|
||||
conv = converter or LibreOfficeConverter.from_settings()
|
||||
if not path:
|
||||
return LibreOfficeResult(ok=False, error="missing_xlsx_path")
|
||||
return conv.convert_xlsx_to_pdf(path)
|
||||
@@ -0,0 +1,267 @@
|
||||
"""
|
||||
PG agent_job 认领壳(与 Redis Stream 二选一真相时用)。
|
||||
|
||||
本文件职责:enqueue / claim / complete / requeue_expired;不含业务处理体。
|
||||
禁止:一把大锁串行所有工单;禁止 Redis wecom:worker:singleton。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import uuid
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.runtime_db import assert_safe_database, connect_psycopg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
@dataclass
|
||||
class JobRecord:
|
||||
"""一条任务行。"""
|
||||
|
||||
id: str
|
||||
job_type: str
|
||||
payload: dict[str, Any] = field(default_factory=dict)
|
||||
state: str = "pending"
|
||||
idempotency_key: str = ""
|
||||
attempts: int = 0
|
||||
last_error: str = ""
|
||||
|
||||
|
||||
class MemoryJobStore:
|
||||
"""内存任务认领壳。"""
|
||||
|
||||
def __init__(self, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
self._rows: dict[str, JobRecord] = {}
|
||||
self._claimed_at: dict[str, float] = {}
|
||||
self._lock = threading.Lock()
|
||||
self._claim_timeout = claim_timeout_sec
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
with self._lock:
|
||||
if idempotency_key:
|
||||
for j in self._rows.values():
|
||||
if j.job_type == job_type and j.idempotency_key == idempotency_key:
|
||||
return False, "duplicate_idempotency_key"
|
||||
job_id = str(uuid.uuid4())
|
||||
self._rows[job_id] = JobRecord(
|
||||
id=job_id,
|
||||
job_type=job_type,
|
||||
payload=dict(payload),
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
return True, job_id
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
import time
|
||||
|
||||
now = time.time()
|
||||
with self._lock:
|
||||
for jid, job in self._rows.items():
|
||||
if job_type and job.job_type != job_type:
|
||||
continue
|
||||
if job.state == "pending" or (
|
||||
job.state == "claimed"
|
||||
and now - self._claimed_at.get(jid, 0) > self._claim_timeout
|
||||
):
|
||||
job.state = "claimed"
|
||||
job.attempts += 1
|
||||
self._claimed_at[jid] = now
|
||||
return JobRecord(
|
||||
id=job.id,
|
||||
job_type=job.job_type,
|
||||
payload=dict(job.payload),
|
||||
state=job.state,
|
||||
idempotency_key=job.idempotency_key,
|
||||
attempts=job.attempts,
|
||||
last_error=job.last_error,
|
||||
)
|
||||
return None
|
||||
|
||||
def complete(self, job_id: str, *, error: str = "", result: Optional[dict] = None) -> None:
|
||||
with self._lock:
|
||||
job = self._rows.get(job_id)
|
||||
if not job:
|
||||
return
|
||||
job.state = "failed" if error else "done"
|
||||
job.last_error = error
|
||||
logger.info("job.memory.complete id=%s state=%s", job_id, job.state)
|
||||
|
||||
|
||||
class PostgresJobStore:
|
||||
"""PG agent_job 认领壳。"""
|
||||
|
||||
def __init__(self, dsn: str, *, claim_timeout_sec: float = 180.0) -> None:
|
||||
assert_safe_database(dsn)
|
||||
self._dsn = dsn
|
||||
self._claim_timeout = int(claim_timeout_sec)
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
job_type: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> tuple[bool, str]:
|
||||
job_id = str(uuid.uuid4())
|
||||
try:
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO agent_job (id, job_type, idempotency_key, payload, state)
|
||||
VALUES (%s, %s, %s, %s::jsonb, 'pending')
|
||||
""",
|
||||
(
|
||||
job_id,
|
||||
job_type,
|
||||
idempotency_key,
|
||||
json.dumps(payload, ensure_ascii=False),
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
return True, job_id
|
||||
except Exception as exc: # noqa: BLE001
|
||||
if "unique" in str(exc).lower() or "duplicate" in str(exc).lower():
|
||||
return False, "duplicate_idempotency_key"
|
||||
raise
|
||||
|
||||
def claim(self, *, worker_id: str, job_type: str = "") -> Optional[JobRecord]:
|
||||
type_filter = "AND job_type = %s" if job_type else ""
|
||||
params: list[Any] = [self._claim_timeout, worker_id]
|
||||
if job_type:
|
||||
params.append(job_type)
|
||||
sql = f"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed' AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
{type_filter}
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state, j.idempotency_key, j.attempts, j.last_error
|
||||
"""
|
||||
# 参数顺序:timeout, [job_type], worker_id — 上面拼法易错,改用固定两段
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
if job_type:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE job_type = %s AND (
|
||||
state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(job_type, str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
else:
|
||||
row = conn.execute(
|
||||
"""
|
||||
WITH cte AS (
|
||||
SELECT id FROM agent_job
|
||||
WHERE state = 'pending'
|
||||
OR (state = 'claimed'
|
||||
AND claimed_at < NOW() - (%s || ' seconds')::interval)
|
||||
ORDER BY created_at
|
||||
FOR UPDATE SKIP LOCKED
|
||||
LIMIT 1
|
||||
)
|
||||
UPDATE agent_job j SET
|
||||
state = 'claimed',
|
||||
claimed_at = NOW(),
|
||||
claimed_by = %s,
|
||||
attempts = j.attempts + 1,
|
||||
updated_at = NOW()
|
||||
FROM cte WHERE j.id = cte.id
|
||||
RETURNING j.id, j.job_type, j.payload, j.state,
|
||||
j.idempotency_key, j.attempts, j.last_error
|
||||
""",
|
||||
(str(self._claim_timeout), worker_id),
|
||||
).fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
return None
|
||||
return JobRecord(
|
||||
id=str(row[0]),
|
||||
job_type=str(row[1]),
|
||||
payload=dict(row[2] or {}),
|
||||
state=str(row[3]),
|
||||
idempotency_key=str(row[4] or ""),
|
||||
attempts=int(row[5] or 0),
|
||||
last_error=str(row[6] or ""),
|
||||
)
|
||||
|
||||
def complete(
|
||||
self,
|
||||
job_id: str,
|
||||
*,
|
||||
error: str = "",
|
||||
result: Optional[dict[str, Any]] = None,
|
||||
) -> None:
|
||||
state = "failed" if error else "done"
|
||||
with connect_psycopg(self._dsn) as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE agent_job SET
|
||||
state = %s,
|
||||
last_error = %s,
|
||||
result = %s::jsonb,
|
||||
updated_at = NOW()
|
||||
WHERE id = %s
|
||||
""",
|
||||
(
|
||||
state,
|
||||
error,
|
||||
json.dumps(result or {}, ensure_ascii=False),
|
||||
job_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
logger.info("job.pg.complete id=%s state=%s", job_id, state)
|
||||
|
||||
|
||||
def create_job_store(*, dsn: str = "", backend: str = "auto", claim_timeout_sec: float = 180.0):
|
||||
"""工厂:Memory 或 PG。"""
|
||||
if backend == "memory" or not dsn:
|
||||
return MemoryJobStore(claim_timeout_sec=claim_timeout_sec)
|
||||
return PostgresJobStore(dsn, claim_timeout_sec=claim_timeout_sec)
|
||||
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
|
||||
|
||||
本文件职责:enqueue / process_stub;正式识别(附件/OCR/字段)后续按 payload.kind 分支。
|
||||
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class RecognitionEnqueueResult:
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
def recognition_stream(redis: Optional[RedisRuntime] = None) -> StreamQueue:
|
||||
"""返回识别 Stream 句柄。"""
|
||||
rt = redis or get_redis_runtime()
|
||||
return rt.recognition_stream
|
||||
|
||||
|
||||
def enqueue_recognition(
|
||||
*,
|
||||
kind: str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
redis: Optional[RedisRuntime] = None,
|
||||
) -> RecognitionEnqueueResult:
|
||||
"""
|
||||
识别任务入队(快回)。
|
||||
|
||||
kind 例:attachment_text / vision / sheet_map。
|
||||
"""
|
||||
rt = redis or get_redis_runtime()
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
rt.client,
|
||||
scope="recognition",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=600,
|
||||
)
|
||||
if not ok:
|
||||
return RecognitionEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
body = {"kind": kind, "payload": payload}
|
||||
entry_id = rt.recognition_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("recognition.enqueue kind=%s id=%s", kind, entry_id)
|
||||
return RecognitionEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
|
||||
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
识别占位:回显 kind,不读附件、不调模型。
|
||||
|
||||
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
|
||||
"""
|
||||
kind = str(data.get("kind") or "unknown")
|
||||
payload = data.get("payload") or {}
|
||||
logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else [])
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"kind": kind,
|
||||
"note": "识别壳:未跑真实抽取",
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"""
|
||||
Worker 侧:消费主账/文件完成等结构化 wake。
|
||||
|
||||
本文件职责:Redis Stream 入队/认领;在 HTTP 类槽内调 GraphRuntime.wake_from_worker。
|
||||
禁止:在企微回调线程调用;禁止用户文字冒充本路径。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.graph.runtime import GraphRuntime
|
||||
from agent.graph.wake import WorkerWakePayload
|
||||
from agent.redis_coord.client import RedisClient
|
||||
from agent.redis_coord.stream_queue import StreamQueue
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
STREAM_WAKE = "stream:wake"
|
||||
WAKE_GROUP = "inquiry-wake-workers"
|
||||
|
||||
|
||||
def wake_stream(client: RedisClient) -> StreamQueue:
|
||||
"""结构化 wake 专用 Stream。"""
|
||||
q = StreamQueue(client=client, stream_suffix=STREAM_WAKE, group=WAKE_GROUP)
|
||||
q.ensure_group()
|
||||
return q
|
||||
|
||||
|
||||
def enqueue_wake(client: RedisClient, payload: WorkerWakePayload) -> str:
|
||||
"""
|
||||
入队 wake(HTTP bridge 快回路径)。
|
||||
|
||||
副作用:XADD;不跑 Graph。
|
||||
"""
|
||||
payload.validate()
|
||||
q = wake_stream(client)
|
||||
return q.enqueue(
|
||||
payload={
|
||||
"thread_id": payload.thread_id,
|
||||
"inquiry_no": payload.inquiry_no,
|
||||
"quote_version": int(payload.quote_version),
|
||||
"wait_version": int(payload.wait_version),
|
||||
"kind": payload.kind,
|
||||
"detail": payload.detail or {},
|
||||
},
|
||||
idempotency_key=f"wake:{payload.thread_id}:{payload.wait_version}:{payload.kind}",
|
||||
)
|
||||
|
||||
|
||||
def claim_and_apply_wake(
|
||||
*,
|
||||
queue: StreamQueue,
|
||||
graph: GraphRuntime,
|
||||
consumer: str,
|
||||
) -> bool:
|
||||
"""
|
||||
认领 1 条并 wake Graph。成功/失败均 ack(骨架防堵死;正式应进死信)。
|
||||
|
||||
返回:是否领到任务。
|
||||
"""
|
||||
claimed = queue.claim(consumer=consumer, count=1, block_ms=200)
|
||||
if not claimed:
|
||||
return False
|
||||
entry_id, data = claimed[0]
|
||||
try:
|
||||
wake = WorkerWakePayload(
|
||||
thread_id=str(data.get("thread_id") or ""),
|
||||
inquiry_no=str(data.get("inquiry_no") or ""),
|
||||
quote_version=int(data.get("quote_version") or 0),
|
||||
wait_version=int(data.get("wait_version") or 0),
|
||||
kind=str(data.get("kind") or ""),
|
||||
detail=dict(data.get("detail") or {}),
|
||||
)
|
||||
graph.wake_from_worker(wake)
|
||||
logger.info("wake 已处理 id=%s thread=%s kind=%s", entry_id, wake.thread_id, wake.kind)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("wake 处理失败 id=%s", entry_id)
|
||||
finally:
|
||||
queue.ack(entry_id)
|
||||
return True
|
||||
@@ -2,25 +2,152 @@
|
||||
主账 HTTP 客户端:只打主账 API,不直连 TMS / MySQL。
|
||||
|
||||
正式写操作必须带幂等键;Graph 成功 ≠ 主账成功。
|
||||
骨架:仅占位客户端。
|
||||
本文件提供可联调的 ping / JSON 请求骨架;不做六态业务分支。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerResponse:
|
||||
"""主账一次 HTTP 调用的结果摘要(不含密钥)。"""
|
||||
|
||||
ok: bool
|
||||
status_code: int
|
||||
data: Any = None
|
||||
error: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LedgerClient:
|
||||
"""
|
||||
主账客户端骨架。
|
||||
主账客户端。
|
||||
|
||||
参数:base_url 来自 BIZ_API_BASE_URL。
|
||||
禁止:在本类拼 TMS 签名或写工单 SQL。
|
||||
base_url:BIZ_API_BASE_URL(新系统多为 http://127.0.0.1:8180/jeecgboot)。
|
||||
超时:默认 10s,禁止无限阻塞 Worker 槽。
|
||||
禁止:在本类拼 TMS HMAC 或写工单 SQL。
|
||||
"""
|
||||
|
||||
base_url: str
|
||||
timeout_seconds: float = 10.0
|
||||
service_token: str = ""
|
||||
_client: Optional[httpx.Client] = field(default=None, repr=False)
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LedgerClient":
|
||||
"""从环境组装客户端。"""
|
||||
cfg = settings or get_settings()
|
||||
return cls(
|
||||
base_url=(cfg.biz_api_base_url or "").rstrip("/"),
|
||||
timeout_seconds=float(getattr(cfg, "biz_api_timeout_seconds", 10) or 10),
|
||||
service_token=(getattr(cfg, "biz_api_token", None) or ""),
|
||||
)
|
||||
|
||||
def _http(self) -> httpx.Client:
|
||||
if self._client is None:
|
||||
headers = {"Accept": "application/json"}
|
||||
if self.service_token:
|
||||
headers["X-Access-Token"] = self.service_token
|
||||
self._client = httpx.Client(
|
||||
base_url=self.base_url,
|
||||
timeout=self.timeout_seconds,
|
||||
headers=headers,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def close(self) -> None:
|
||||
"""关闭连接池。"""
|
||||
if self._client is not None:
|
||||
self._client.close()
|
||||
self._client = None
|
||||
|
||||
def health_url(self) -> str:
|
||||
"""返回主账健康检查 URL,供后续联调;骨架不发请求。"""
|
||||
return f"{self.base_url.rstrip('/')}/health"
|
||||
"""健康检查相对路径候选的完整 URL(日志用)。"""
|
||||
return f"{self.base_url}/"
|
||||
|
||||
def ping(self) -> LedgerResponse:
|
||||
"""
|
||||
探测主账是否可达。
|
||||
|
||||
尝试 GET / 或常见健康路径;4xx/5xx 记入 error,不抛给回调线程。
|
||||
副作用:出站 HTTP。
|
||||
"""
|
||||
if not self.base_url:
|
||||
return LedgerResponse(ok=False, status_code=0, error="BIZ_API_BASE_URL 未配置")
|
||||
paths = ["/", "/sys/randomImage/ping-check"]
|
||||
last: LedgerResponse = LedgerResponse(ok=False, status_code=0, error="no_attempt")
|
||||
for path in paths:
|
||||
try:
|
||||
resp = self._http().get(path)
|
||||
last = LedgerResponse(
|
||||
ok=resp.status_code < 500,
|
||||
status_code=resp.status_code,
|
||||
data={"path": path},
|
||||
)
|
||||
if resp.status_code < 500:
|
||||
logger.info(
|
||||
"ledger.ping ok status=%s path=%s",
|
||||
resp.status_code,
|
||||
path,
|
||||
)
|
||||
return last
|
||||
except httpx.HTTPError as exc:
|
||||
last = LedgerResponse(ok=False, status_code=0, error=str(exc))
|
||||
logger.warning("ledger.ping 失败 path=%s err=%s", path, exc)
|
||||
return last
|
||||
|
||||
def request_json(
|
||||
self,
|
||||
method: str,
|
||||
path: str,
|
||||
*,
|
||||
json_body: Optional[dict[str, Any]] = None,
|
||||
idempotency_key: str = "",
|
||||
) -> LedgerResponse:
|
||||
"""
|
||||
通用 JSON 请求骨架。
|
||||
|
||||
写操作应传入 idempotency_key(写入头 X-Idempotency-Key)。
|
||||
不做业务语义解析;调用方根据主账合同处理 result。
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if idempotency_key:
|
||||
headers["X-Idempotency-Key"] = idempotency_key
|
||||
try:
|
||||
resp = self._http().request(
|
||||
method.upper(),
|
||||
path,
|
||||
json=json_body,
|
||||
headers=headers,
|
||||
)
|
||||
data: Any
|
||||
try:
|
||||
data = resp.json()
|
||||
except ValueError:
|
||||
data = {"text": resp.text[:500]}
|
||||
ok = 200 <= resp.status_code < 300
|
||||
if not ok:
|
||||
logger.warning(
|
||||
"ledger.request 非成功 status=%s path=%s",
|
||||
resp.status_code,
|
||||
path,
|
||||
)
|
||||
return LedgerResponse(ok=ok, status_code=resp.status_code, data=data)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("ledger.request 异常 path=%s err=%s", path, exc)
|
||||
return LedgerResponse(ok=False, status_code=0, error=str(exc))
|
||||
|
||||
|
||||
from agent.ledger.api_shell import LedgerApiShell # noqa: E402
|
||||
|
||||
__all__ = ["LedgerClient", "LedgerResponse", "LedgerApiShell"]
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""
|
||||
主账 API 方法签名壳。
|
||||
|
||||
本文件职责:列出智能体将调用的主账端点形状;默认返回 not_implemented,不假装成功。
|
||||
禁止:直连 TMS / MySQL;Graph 成功不能冒充本文件成功。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.ledger import LedgerClient, LedgerResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LedgerApiShell:
|
||||
"""
|
||||
主账业务 API 空壳(签名齐、实现空)。
|
||||
|
||||
正式:各方法改走 request_json 打真实路径;路径以主账 OpenAPI 为准。
|
||||
"""
|
||||
|
||||
def __init__(self, client: Optional[LedgerClient] = None) -> None:
|
||||
self.client = client or LedgerClient.from_settings()
|
||||
|
||||
def _nyi(self, name: str) -> LedgerResponse:
|
||||
logger.info("ledger.api_shell.%s not_implemented", name)
|
||||
return LedgerResponse(
|
||||
ok=False,
|
||||
status_code=501,
|
||||
error=f"not_implemented:{name}",
|
||||
data={"method": name},
|
||||
)
|
||||
|
||||
def create_ticket(
|
||||
self,
|
||||
*,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""建单壳。"""
|
||||
_ = (payload, idempotency_key)
|
||||
return self._nyi("create_ticket")
|
||||
|
||||
def transition_ticket(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
to_status: str,
|
||||
idempotency_key: str,
|
||||
reason: str = "",
|
||||
) -> LedgerResponse:
|
||||
"""六态迁移壳。"""
|
||||
_ = (inquiry_no, to_status, idempotency_key, reason)
|
||||
return self._nyi("transition_ticket")
|
||||
|
||||
def get_ticket(self, *, inquiry_no: str) -> LedgerResponse:
|
||||
"""查工单壳。"""
|
||||
_ = inquiry_no
|
||||
return self._nyi("get_ticket")
|
||||
|
||||
def upsert_quote_version(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
quote_version: int,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""报价版本壳。"""
|
||||
_ = (inquiry_no, quote_version, payload, idempotency_key)
|
||||
return self._nyi("upsert_quote_version")
|
||||
|
||||
def register_attachment(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
object_key: str,
|
||||
meta: dict[str, Any],
|
||||
idempotency_key: str,
|
||||
) -> LedgerResponse:
|
||||
"""附件元数据登记壳。"""
|
||||
_ = (inquiry_no, object_key, meta, idempotency_key)
|
||||
return self._nyi("register_attachment")
|
||||
|
||||
def get_staff_by_wecom_id(self, *, wecom_id: str) -> LedgerResponse:
|
||||
"""人员角色壳(按已有 wecom_id)。"""
|
||||
_ = wecom_id
|
||||
return self._nyi("get_staff_by_wecom_id")
|
||||
|
||||
def list_quote_templates(self, *, enabled_only: bool = True) -> LedgerResponse:
|
||||
"""
|
||||
报价模板列表壳。
|
||||
|
||||
真相:后台上传/启用;智能体只读已启用项,再经对象存储取字节。
|
||||
"""
|
||||
_ = enabled_only
|
||||
return self._nyi("list_quote_templates")
|
||||
|
||||
def wake_agent_ack(
|
||||
self,
|
||||
*,
|
||||
inquiry_no: str,
|
||||
quote_version: int,
|
||||
wait_version: int,
|
||||
) -> LedgerResponse:
|
||||
"""主账侧唤醒回执壳(智能体被唤醒后可选回写)。"""
|
||||
_ = (inquiry_no, quote_version, wait_version)
|
||||
return self._nyi("wake_agent_ack")
|
||||
@@ -1,6 +1,20 @@
|
||||
"""
|
||||
LLM 网关与队列:DeepSeek / 千问 mode schema、认领、重试。
|
||||
LLM 网关与队列:DeepSeek / 千问 mode、入队、认领(Worker 侧后续接)。
|
||||
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁。
|
||||
骨架:仅包占位。
|
||||
禁止:在本包维护工单六态;禁止 Redis 全局 Worker 单例锁;禁止回调线程等完整 LLM。
|
||||
"""
|
||||
|
||||
from agent.llm.gateway import LlmEnqueueResult, LlmGateway
|
||||
from agent.llm.http_client import LlmHttpClient, LlmHttpResult
|
||||
from agent.llm.mode_registry import invoke_mode_shell, list_mode_shells
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
__all__ = [
|
||||
"LlmMode",
|
||||
"LlmGateway",
|
||||
"LlmEnqueueResult",
|
||||
"LlmHttpClient",
|
||||
"LlmHttpResult",
|
||||
"invoke_mode_shell",
|
||||
"list_mode_shells",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""
|
||||
LLM 网关骨架:入队 Redis Stream,不在回调线程等完整结果。
|
||||
|
||||
本文件职责:校验 mode、短幂等、XADD;真实模型 HTTP 后续按 mode 实现。
|
||||
禁止:wait_result 堵在请求线程;禁止本包维护工单六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
from agent.redis_coord import get_redis_runtime, try_acquire_idempotency
|
||||
from agent.redis_coord.runtime import RedisRuntime
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmEnqueueResult:
|
||||
"""入队结果。"""
|
||||
|
||||
accepted: bool
|
||||
entry_id: str = ""
|
||||
reason: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmGateway:
|
||||
"""
|
||||
LLM 网关。
|
||||
|
||||
调用:HTTP/Handler 只 enqueue;Worker 槽 claim 后调 invoke_stub/未来真调用。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
redis: RedisRuntime
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmGateway":
|
||||
cfg = settings or get_settings()
|
||||
return cls(settings=cfg, redis=get_redis_runtime(cfg))
|
||||
|
||||
def enqueue(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
payload: dict[str, Any],
|
||||
idempotency_key: str = "",
|
||||
) -> LlmEnqueueResult:
|
||||
"""
|
||||
将任务写入 LLM Stream。
|
||||
|
||||
重复 idempotency_key(TTL 内)返回 accepted=False。
|
||||
副作用:可能写 Redis;不调外部模型。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
try:
|
||||
LlmMode(mode_val)
|
||||
except ValueError:
|
||||
return LlmEnqueueResult(accepted=False, reason=f"unknown_mode:{mode_val}")
|
||||
|
||||
if idempotency_key:
|
||||
ok = try_acquire_idempotency(
|
||||
self.redis.client,
|
||||
scope=f"llm:{mode_val}",
|
||||
idempotency_key=idempotency_key,
|
||||
ttl_seconds=300,
|
||||
)
|
||||
if not ok:
|
||||
return LlmEnqueueResult(accepted=False, reason="duplicate_idempotency_key")
|
||||
|
||||
body = {"mode": mode_val, "payload": payload}
|
||||
entry_id = self.redis.llm_stream.enqueue(
|
||||
payload=body,
|
||||
idempotency_key=idempotency_key,
|
||||
)
|
||||
logger.info("llm.enqueue mode=%s id=%s", mode_val, entry_id)
|
||||
return LlmEnqueueResult(accepted=True, entry_id=entry_id)
|
||||
|
||||
def invoke_stub(self, *, mode: LlmMode | str, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
模型调用占位:返回固定结构,不访问外网。
|
||||
|
||||
正式实现按 mode 调 DeepSeek/千问;本方法仅供骨架与单测。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if mode_val != LlmMode.ORDERED_ACTIONS.value:
|
||||
from agent.llm.mode_registry import invoke_mode_shell
|
||||
|
||||
return invoke_mode_shell(mode_val, payload=payload)
|
||||
return {
|
||||
"ok": True,
|
||||
"stub": True,
|
||||
"mode": mode_val,
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
"note": "骨架不调用真实模型",
|
||||
}
|
||||
|
||||
def invoke(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
allow_network: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Worker 槽内调用模型。
|
||||
|
||||
allow_network=False:走 stub(默认,防误打费)。
|
||||
allow_network=True:走 LlmHttpClient(需已配 API Key)。
|
||||
"""
|
||||
if not allow_network:
|
||||
return self.invoke_stub(mode=mode, payload={"messages": len(messages)})
|
||||
from agent.llm.http_client import LlmHttpClient
|
||||
|
||||
result = LlmHttpClient.from_settings(self.settings).chat(mode=mode, messages=messages)
|
||||
return {
|
||||
"ok": result.ok,
|
||||
"stub": False,
|
||||
"provider": result.provider,
|
||||
"content": result.content,
|
||||
"error": result.error,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""
|
||||
LLM 出站 HTTP 客户端(DeepSeek / 千问兼容 OpenAI Chat)。
|
||||
|
||||
本文件职责:带超时的 chat.completions 调用;缺 key / 未确认数据用途时明确失败。
|
||||
禁止:在企微回调线程同步长等;正式长调用走 Worker 槽 + 队列。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from agent.config import Settings, get_settings
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpResult:
|
||||
ok: bool
|
||||
content: str = ""
|
||||
raw: Optional[dict[str, Any]] = None
|
||||
error: str = ""
|
||||
provider: str = ""
|
||||
tool_calls: list[dict[str, Any]] | None = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class LlmHttpClient:
|
||||
"""
|
||||
兼容 OpenAI 的 Chat Completions 客户端。
|
||||
|
||||
DeepSeek / 千问 DashScope compatible-mode 共用此壳。
|
||||
"""
|
||||
|
||||
settings: Settings
|
||||
timeout_seconds: float = 60.0
|
||||
|
||||
@classmethod
|
||||
def from_settings(cls, settings: Optional[Settings] = None) -> "LlmHttpClient":
|
||||
return cls(settings=settings or get_settings())
|
||||
|
||||
def _provider_for_mode(self, mode: LlmMode | str) -> tuple[str, str, str]:
|
||||
"""
|
||||
返回 (provider, base_url, api_key)。
|
||||
|
||||
A/B 文本 → DeepSeek;附件/视觉 → 千问。
|
||||
"""
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
visionish = mode_val in {
|
||||
LlmMode.ATTACHMENT_TEXT.value,
|
||||
LlmMode.VISION.value,
|
||||
LlmMode.SHEET_MAP.value,
|
||||
}
|
||||
if visionish:
|
||||
return (
|
||||
"qwen",
|
||||
(self.settings.qwen_base_url or "").rstrip("/"),
|
||||
self.settings.qwen_api_key or "",
|
||||
)
|
||||
return (
|
||||
"deepseek",
|
||||
(self.settings.deepseek_base_url or "").rstrip("/"),
|
||||
self.settings.deepseek_api_key or "",
|
||||
)
|
||||
|
||||
def _model_for(self, provider: str, mode: LlmMode | str) -> str:
|
||||
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
if provider == "qwen":
|
||||
if mode_val == LlmMode.VISION.value:
|
||||
return self.settings.qwen_vl_model or "qwen-vl-plus"
|
||||
return self.settings.qwen_text_model or "qwen-plus"
|
||||
return self.settings.deepseek_model or "deepseek-chat"
|
||||
|
||||
def chat(
|
||||
self,
|
||||
*,
|
||||
mode: LlmMode | str,
|
||||
messages: list[dict[str, Any]],
|
||||
temperature: float = 0.0,
|
||||
tools: Optional[list[dict[str, Any]]] = None,
|
||||
tool_choice: Any = None,
|
||||
) -> LlmHttpResult:
|
||||
"""
|
||||
发起一次 chat.completions(可带 tools)。
|
||||
|
||||
副作用:出站 HTTPS;超时由 timeout_seconds 限制。
|
||||
未确认 LLM_DATA_USAGE_CONFIRMED 时拒绝调用(prompt/04)。
|
||||
"""
|
||||
if not self.settings.llm_data_usage_confirmed:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error="llm_data_usage_not_confirmed",
|
||||
provider="none",
|
||||
)
|
||||
provider, base, key = self._provider_for_mode(mode)
|
||||
if not key or not base:
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"{provider}_not_configured",
|
||||
provider=provider,
|
||||
)
|
||||
model = self._model_for(provider, mode)
|
||||
url = f"{base}/chat/completions"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
body: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if tools:
|
||||
body["tools"] = tools
|
||||
if tool_choice is not None:
|
||||
body["tool_choice"] = tool_choice
|
||||
try:
|
||||
with httpx.Client(timeout=self.timeout_seconds) as client:
|
||||
resp = client.post(url, headers=headers, json=body)
|
||||
data = resp.json() if resp.content else {}
|
||||
if resp.status_code >= 400:
|
||||
logger.warning(
|
||||
"llm.http 失败 provider=%s status=%s",
|
||||
provider,
|
||||
resp.status_code,
|
||||
)
|
||||
return LlmHttpResult(
|
||||
ok=False,
|
||||
error=f"http_{resp.status_code}",
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
)
|
||||
choices = (data or {}).get("choices") or []
|
||||
content = ""
|
||||
tool_calls: list[dict[str, Any]] = []
|
||||
if choices:
|
||||
msg = (choices[0].get("message") or {})
|
||||
content = str(msg.get("content") or "")
|
||||
raw_calls = msg.get("tool_calls") or []
|
||||
if isinstance(raw_calls, list):
|
||||
tool_calls = [c for c in raw_calls if isinstance(c, dict)]
|
||||
return LlmHttpResult(
|
||||
ok=True,
|
||||
content=content,
|
||||
raw=data if isinstance(data, dict) else None,
|
||||
provider=provider,
|
||||
tool_calls=tool_calls or None,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
logger.warning("llm.http 异常 provider=%s err=%s", provider, exc)
|
||||
return LlmHttpResult(ok=False, error=str(exc), provider=provider)
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问:附件文本 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.ATTACHMENT_TEXT
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "attachment_text",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "attachment_text_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
DeepSeek B:文本字段抽取 mode 壳。
|
||||
|
||||
本文件职责:声明 mode 元数据与空 invoke_shell;不写字段合同激活规则。
|
||||
禁止:在本文件改主账六态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.EXTRACT_FIELDS
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
"""返回本 mode 的框架元数据(无 prompt 正文业务)。"""
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "text_field_extract",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""空壳:不调模型,返回未实现。"""
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "extract_fields_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
群报价抽取 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.GROUP_QUOTE
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "deepseek",
|
||||
"purpose": "group_quote",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "group_quote_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
"""
|
||||
LLM mode 空壳注册表:按 mode 名取 invoke_shell。
|
||||
|
||||
本文件只做分发到各 mode_* 模块;ordered_actions 走 routing/DeepSeekA。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Callable
|
||||
|
||||
from agent.llm import mode_attachment_text, mode_extract_fields, mode_group_quote, mode_sheet_map, mode_vision
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
_SHELLS: dict[str, Callable[..., dict[str, Any]]] = {
|
||||
LlmMode.EXTRACT_FIELDS.value: mode_extract_fields.invoke_shell,
|
||||
LlmMode.ATTACHMENT_TEXT.value: mode_attachment_text.invoke_shell,
|
||||
LlmMode.VISION.value: mode_vision.invoke_shell,
|
||||
LlmMode.GROUP_QUOTE.value: mode_group_quote.invoke_shell,
|
||||
LlmMode.SHEET_MAP.value: mode_sheet_map.invoke_shell,
|
||||
}
|
||||
|
||||
|
||||
def invoke_mode_shell(mode: str | LlmMode, *, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""调用对应 mode 空壳;未知 mode 返回错误结构。"""
|
||||
key = mode.value if isinstance(mode, LlmMode) else str(mode)
|
||||
fn = _SHELLS.get(key)
|
||||
if fn is None:
|
||||
return {"ok": False, "stub": True, "mode": key, "error": "unknown_or_unregistered_mode"}
|
||||
return fn(payload=payload)
|
||||
|
||||
|
||||
def list_mode_shells() -> list[str]:
|
||||
"""已注册空壳 mode 名。"""
|
||||
return sorted(_SHELLS.keys())
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
报价工作表映射 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.SHEET_MAP
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "sheet_map",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "sheet_map_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
千问视觉:读图 mode 壳。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from agent.llm.modes import LlmMode
|
||||
|
||||
MODE = LlmMode.VISION
|
||||
|
||||
|
||||
def mode_meta() -> dict[str, Any]:
|
||||
return {
|
||||
"mode": MODE.value,
|
||||
"provider": "qwen",
|
||||
"purpose": "vision",
|
||||
"implemented": False,
|
||||
}
|
||||
|
||||
|
||||
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"ok": False,
|
||||
"stub": True,
|
||||
"mode": MODE.value,
|
||||
"error": "vision_shell_not_implemented",
|
||||
"echo_keys": sorted(payload.keys()),
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user