落地并存测服全端:智能体 Graph/路由、后台切 8180、主账与部署脚本齐备,便于线上重发与验收。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-09-13 21:15:58 +08:00
co-authored by Cursor
parent 0eb1adecc8
commit 8103b3b651
143 changed files with 8968 additions and 277 deletions
+4 -2
View File
@@ -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
+5
View File
@@ -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
View File
@@ -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 只替换传输段。
+67
View File
@@ -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
View File
@@ -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
View File
@@ -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": []
}
+102
View File
@@ -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": []
}
+163
View File
@@ -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"
}
]
}
+58
View File
@@ -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
View File
@@ -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)
+103
View File
@@ -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
}
+44
View File
@@ -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'
+13
View File
@@ -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'
+9
View File
@@ -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'
+77
View File
@@ -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'
+119
View File
@@ -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") }
}
+107
View File
@@ -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'
+52
View File
@@ -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'
+18
View File
@@ -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'
+85
View File
@@ -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'
+17
View File
@@ -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'
+34
View File
@@ -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'
+104
View File
@@ -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'
+34
View File
@@ -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'
+16
View File
@@ -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'
+6
View File
@@ -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'
+40
View File
@@ -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'
+26
View File
@@ -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'
+27
View File
@@ -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'
+45
View File
@@ -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)
}
}
+20
View File
@@ -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'
+62
View File
@@ -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 -
+45
View File
@@ -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'
+29
View File
@@ -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'
+90
View File
@@ -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'
+74 -61
View File
@@ -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'
+56 -65
View File
@@ -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)
+9
View File
@@ -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'
+16
View File
@@ -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'
+108
View File
@@ -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)
+74
View File
@@ -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/; }
}
+60
View File
@@ -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
+24
View File
@@ -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
View File
@@ -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}
+88 -4
View File
@@ -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
+64 -7
View File
@@ -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}
+295
View File
@@ -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),
}
+50 -9
View File
@@ -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()
+12 -10
View File
@@ -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 {}),
)
+73 -1
View File
@@ -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:
+262
View File
@@ -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())
+26 -3
View File
@@ -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",
]
+76
View File
@@ -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
+91
View File
@@ -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"}
+29
View File
@@ -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,
)
+197
View File
@@ -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
+68
View File
@@ -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())
+32
View File
@@ -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
+61
View File
@@ -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 {},
},
}
+5 -1
View File
@@ -2,5 +2,9 @@
Handler 包:一类意图 / 一类卡片一个模块。
禁止:单文件处理全部卡片;禁止把 Schema 校验复制粘贴进每个 handler(放 schema/policy)。
骨架:仅包占位。
当前:echo 联调 + 其余意图空壳(待接 RouteDecision)。
"""
from agent.handlers.echo_text import handle_echo_text
__all__ = ["handle_echo_text"]
+23
View File
@@ -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 "-",
)
+2 -2
View File
@@ -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,
)
+2
View File
@@ -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
+185 -35
View File
@@ -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)
+62
View File
@@ -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": "存档壳:未下载未登记"}
+146
View File
@@ -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,
)
)
+102
View File
@@ -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)
+80
View File
@@ -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)
+267
View File
@@ -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)
+79
View File
@@ -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": "识别壳:未跑真实抽取",
}
+82
View File
@@ -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
+134 -7
View File
@@ -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"]
+111
View File
@@ -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")
+17 -3
View File
@@ -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",
]
+127
View File
@@ -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,
}
+156
View File
@@ -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()),
}
+34
View File
@@ -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())
+30
View File
@@ -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()),
}
+30
View File
@@ -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