通道弃洞自愈+参数列放宽TEXT+运维清标记按钮+iPad浮层teleport与触屏直连+信号计划标

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
zlt 2026-09-01 09:18:00 +08:00
parent 3e2a243678
commit a507e7afbc
12 changed files with 433 additions and 18 deletions

122
DEVLOG.md
View File

@ -1139,7 +1139,127 @@ tlai4090 同法。无新 env、无 DDL。
存量合计/底行) 与图表单点标记都应出现。 存量合计/底行) 与图表单点标记都应出现。
**还欠着什么** **还欠着什么**
上节点的三个口径拍板照旧欠着; 图表逐像素对齐照旧按需。 1. **样式仍有出入 (未决, 新会话第一件事)**: 隐藏行修复后使用者反馈「表格样式还是不对」,
具体差异点没来得及描述。新会话需要: 让使用者指认哪里不对 (截图或把导出的 xlsx 发回),
对照基准用仓库根目录的 公司原表-量化数据2026.8.3.xlsx (2026-08-28 已入库), 逐区 diff
(候选疑点: O/P 融资列折叠是否符合预期、黄条/粉底跨的列数、字体字号、列宽、边框、
图表配色与轴样式、标题行)。改样式动 app/assets/publish_template.xlsx 与
publish_export.py, 改完跑 scripts/test_wiring.py + 全套 588 例。
2. 部署状态待确认: 本节点三个文件已回写开发机工作区, commit/push 与两台服务器
(真机 188 / 模拟机 tlai4090) 的 git pull + make deploy 是否已做, 新会话先问。
3. 上节点的三个口径拍板照旧欠着; 图表逐像素对齐照旧按需。
---
## 2026-08-31 · 查透「通道要人工做一次全量对账」+ 参数列 1406 截断 + iPad 点不开侧栏 + 信号标计划
**做了什么**
使用者报三件事: 页面一直挂「通道要人工做一次全量对账」不知怎么操作; iPad Pro 上点
左右窄条 (信号流 / 待办与操作) 展不开; 想在速览「没持仓的票」段标出选股计划里的票。
只读排查模拟机 (155), 挖出三个真问题, 全部修掉:
一、**水位卡死三天** (对账横幅的根源)。2026-08-28 17:21 pms-ws 重启首连时, 对端补发的
99 条 pong 全部验签失败被丢 (SIG_INVALID, 一次性, 之后再无), 每条占一个 seq → 水位卡在
365003 洞前; `_baselined` 一经置位终生不再对齐, 会话内的洞永远等不来 (ws 是有序流,
§6.1 补发只在重连握手时), 暂存涨到 5.3 万条、ack 停摆、重连时对端整段重发 4.9 万条。
修法两层: `_session` 每连接重置 `_baselined` (重连即重对齐, 连续时是空操作);
新增**弃洞前进** watchdog (`_maybe_skip_gap` + 纯函数 `ws_codec.skip_gap`): 水位在同一
位置卡满 PMS_QMT_GAP_SKIP_SEC (默认 300 秒, 参数中心可调, 0=关) 就按 §6.2 认定补不出
来 —— 推水位到已到达最高序号、清暂存、置 resync 标记走全量对账。暂存序号全部来自验签
通过的消息, 推进目标不受伪造数据影响。另加 sig_invalid / pending_gap / gap_skips 三个
stat 计数, ws_smoke status 逐条给人话告警。
二、**pms_runtime_param.param_value VARCHAR(200) 装不下会长大的 JSON** (每分钟两处
1406 截断错的共同病根)。清场名单 PMS_EXIT_CLEANUP_DONE (30 只闭仓票就超长; 存不上时
「每次闭仓只清一次」防呆失守, 闭仓后新下的建仓单会被清场误撤) 与回放游标
PMS_REPLAY_CURSOR (08-28 改 v2 带 seen 去重字典后必超; 游标推不动, 表回放每分钟报
「游标写入失败」)。修法: DDL 放宽为 TEXT, 已有库跑新脚本
scripts/migrate_param_value_text.py (幂等, 默认演练, --yes 执行, 显式过 map_tables,
batch19 [K2] 白名单同步加上)。
三、**页面**: ① 运维抽屉补上「清除通道对账标记」按钮 —— 后端接口
(POST /api/ws-channel/clear-resync, 管理员) 早就有, 页面一直没入口, 横幅让人「经运维
抽屉清除」却无处可点; 按钮带确认弹窗, 只在 resync_required 时出现。② iPad 点不开侧栏:
本地台架实测鼠标路径完好, 判为 iPad Safari 对非控件元素点按不合成 click 的老毛病;
修法是窄条与蒙层加 touchend 直连 (touchstart 记起点, 位移 >12px 判滑动误触不开,
preventDefault 挡合成 click 防双触发), CSS 加 touch-action:manipulation 与
@media(pointer:coarse) 命中区加大 (窄条 40→48px)。鼠标端零改动 (无 touch 事件, 走原
@click; coarse 媒询不命中)。③ 速览「没持仓的票」段: 行首加蓝色「计划」标 —— 票在
今天上游选股计划主榜 (planRows, 登录即随 loadPlan 加载) 就标, 段标题写明含义;
「更早的信号」段同样标 (仅非持仓行)。
顺带查明账实差异现状: 账本与 ws 快照差 5 只 (002436 少 1600 / 002709 少 300 /
300308 多 900 / 301308 多 100 / 688676 多 3600), 正是缺口期丢成交所致; 爆炸半径闸
判据是「>5 只且 >34%」, 5 只不触发, 15:10 日终结算的全量对账会自动照 ws 修正留痕。
另发现 trading_position 表只有 2 只与 ws 快照 (7 只) 不同步, 事实源仲裁已按 ws 为准
并告警 —— 「谁在写那张表」要与 QMT 侧核对。
**动了哪些文件**
app/ws/runner.py (每连接重置基线 + 弃洞 watchdog + sig_invalid 计数)、
app/core/ws_codec.py (skip_gap)、app/web/static/index.html (清标记按钮 + 触屏直连 +
计划标)、ddl_pms_v1.sql (param_value → TEXT)、新增 scripts/migrate_param_value_text.py、
scripts/ws_smoke.py (三条新告警)、scripts/test_batch6_units.py (弃洞 3 例, 65→68)、
scripts/test_batch19_units.py ([K2] 白名单)、scripts/run_tests.py (共 591 例)、README.md。
**部署方式**
模拟机 155: git pull 后 make deploy (源码打镜像必须重建); 之后跑一次
`docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py --yes`
(ALTER 秒级, 不用重启, 新库建表自带 TEXT 不用跑)。真机 188 同法, 时点等 QMT 侧联调
恢复时一并 (它同样带着 VARCHAR(200) 和旧 runner)。
**真机判收**
开发机: 全部 20 套 591 例 ALL SUITES PASS; 本地台架 1024×1366 实测 —— 鼠标点窄条开浮层、
触摸序列开浮层、滑动 380px 误触不开、触摸蒙层关闭、控制台无脚本错误。
部署后待判收: ① pms-ws 起来后 5 分钟内日志出现「水位弃洞前进」且 make ws-status 水位
追到当前、暂存归零; ② 页面「账本对账」差异清零后点「清除通道对账标记」, 横幅消失且
不复发; ③ worker 日志不再刷 1406; ④ iPad 实机点窄条能展开 (模拟器只能模拟到触摸事件
层, Safari 真机行为要使用者过手); ⑤ 速览出现蓝「计划」标。
**还欠着什么**
1. 155 部署与迁移待用户批准执行 (容器重建 + ALTER TABLE 各要一次授权)。
2. 全量对账清差异 + 清标记按钮点掉横幅, 待部署后走一遍 (15:10 结算大概率已自动修正
差异, 部署后核对 make watch 与页面对账结果即可)。
3. 与 QMT 模拟侧核对两件: 08-28 17:21 那批 pong 为何验签失败 (重放重签? 密钥轮换?);
trading_position 表还该不该有人写。
4. iPad 实机判收 (第 ④ 条) 待使用者过手。
5. 真机 188 的同款部署与迁移, 等 QMT 侧联调窗口。
---
## 2026-09-01 · iPad 浮层实锅重修: teleport 出栈 + 只用老写法的浮层几何
**做了什么**
使用者发来 iPad Pro 实机截图: 点右窄条浮层**能开但渲染烂了** —— 面板无背景、无遮罩,
内容与底下持仓表的浮动操作列穿插, 选股计划抽屉的深色提示块也粘在屏上。与 08-31 的判断
修正一处: 实机上点击事件是通的, 坏的是浮层渲染。两个独立机制都补死:
一, **层叠上下文困局**: 浮层 aside 在 position:sticky 的 side 容器里, sticky 自成层叠
上下文, 固定定位的浮层被困在里面, 与主区 el-table 浮动列 (自带 z-index) 的绘制次序在
部分 Safari 上穿插。修法: 两个浮层与遮罩用 Vue 内建 teleport 挂到 body 下
(:disabled 绑定「非浮层态」, 常驻停靠时原地不动, 行为与从前逐字节相同)。
二, **新式 CSS 整条被丢**: 浮层宽度用 min(560px,88vw)、遮罩用 inset:0, 旧 Safari
(min 需 11.1+, inset 需 14.5+) 会把整条声明丢掉 —— 遮罩变 0×0 不可见。改成
width+max-width 与 top/left/right/bottom 四边的等价老写法, 面板背景加一条 #fff 兜底
再让变量覆盖。顺带: 触屏上 ? 号悬停气泡点按后粘住不消失 (截图那块黑浮块),
@media(hover:none) 下不出气泡; 新增 ?uidbg=1 远程排查小牌 (印浏览器 UA / 视口 /
停靠态 / 浮层态, 平时不出现), iPad 再有样式问题一张截图即可定位。
**动了哪些文件**
app/web/static/index.html (teleport ×3、浮层几何老写法、背景兜底、气泡抑制、uidbg)。
**部署方式**
随 08-31 那批一起: 155 git pull 后 make deploy, 另跑一次 migrate_param_value_text.py --yes。
**真机判收**
开发机台架两形态实测: 1024×1366 浮层 —— 面板已挂 body 下、position fixed、宽 560、
白底、遮罩 display:block、z 2010; 1920×1080 常驻 —— 两栏 parent 仍是 side 容器
(teleport 未触发)、窄条隐藏、三栏布局与改前一致; 控制台无脚本错误。
部署后待判收: iPad 实机点窄条, 浮层应当白底带阴影、背后有半透明遮罩、点遮罩即关。
**还欠着什么**
1. 随 08-31 节点: 155 部署+迁移待批; iPad 实机判收待使用者过手 (地址后加 ?uidbg=1
截图可带上排查小牌)。
2. 模拟仓重启方案已给使用者建议 (彻底清盘: 盘中紧急离场卖光 → 收盘后 purge-dead-orders
+ reset-ledger → 次日从零自动建仓; 清账前先存档), 待拍板。
--- ---
<!-- <!--

View File

@ -66,7 +66,7 @@ scripts/
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例 test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 21 例
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例 test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 11 例
test_batch5_units.py 决策系统信号流解析与消化口径 8 例 test_batch5_units.py 决策系统信号流解析与消化口径 8 例
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 65 test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 68
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例 test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 32 例
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例 test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 60 例
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例 test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 49 例

View File

@ -558,6 +558,23 @@ def cold_start_baseline(last_seq: int, first_seq: int) -> tuple:
return int(first_seq) - 1, int(last_seq) > 0 return int(first_seq) - 1, int(last_seq) > 0
def skip_gap(last_seq: int, arrived: set) -> tuple:
"""§6.2 弃洞前进: 认定 (last_seq, max(arrived)] 里未到的序号永远不会来。
返回 (新水位 = 已到达的最高序号, 洞数)arrived 为空则原地不动
只应在水位卡死已满处置窗口时调用 ( runner._maybe_skip_gap 的说明) ws
单连接有序流, 会话内的洞不会自愈; 到点仍未收敛就是对端补不出来,
cold_start_baseline 处置真缺口同一语义: 推水位 resync走全量对账
arrived 里的序号全部来自验签通过的消息, 推进目标不受未验签数据影响
"""
if not arrived:
return int(last_seq), 0
base = int(last_seq)
top = max(int(s) for s in arrived)
got = len({int(s) for s in arrived if base < int(s) <= top})
return top, max(0, (top - base) - got)
def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple: def next_watermark(last_seq: int, arrived: set, seq: int) -> tuple:
"""收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。 """收到 seq 后推进连续水位。返回 (新水位, 新的乱序暂存集合)。

View File

@ -261,10 +261,14 @@ body.dock-r:not(.r-fold) .side-r{width:var(--rail-w-r);}
display:none;flex-direction:column;max-height:calc(100vh - 116px);overflow:hidden;} display:none;flex-direction:column;max-height:calc(100vh - 116px);overflow:hidden;}
body.dock-l:not(.l-fold) #railL{display:flex;} body.dock-l:not(.l-fold) #railL{display:flex;}
body.dock-r:not(.r-fold) #railR{display:flex;} body.dock-r:not(.r-fold) #railR{display:flex;}
.rail.open{display:flex;position:fixed;top:96px;bottom:18px;width:min(560px,88vw);max-height:none; /* 浮层几何刻意只用老写法 (2026-09-01): min()/inset 这类新函数在旧 Safari 上整条声明
被丢, 浮层就成了没背景没遮罩的"透明面板" (iPad 实锅)。宽度用 width+max-width 等价
表达; 背景先给一条固定色兜底再让变量覆盖 */
.rail.open{display:flex;position:fixed;top:96px;bottom:18px;width:560px;max-width:88vw;max-height:none;
background:#ffffff;background:var(--surface);
z-index:2010;box-shadow:0 12px 38px rgba(17,17,16,.2),0 2px 8px rgba(17,17,16,.08);} z-index:2010;box-shadow:0 12px 38px rgba(17,17,16,.2),0 2px 8px rgba(17,17,16,.08);}
#railL.open{left:16px;} #railR.open{right:16px;} #railL.open{left:16px;} #railR.open{right:16px;}
.rail-scrim{position:fixed;inset:0;background:rgba(17,17,16,.3);z-index:2005;display:none;} .rail-scrim{position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(17,17,16,.3);z-index:2005;display:none;}
.rail-scrim.on{display:block;} .rail-scrim.on{display:block;}
.rail-h{display:flex;align-items:center;gap:7px;padding:9px 11px;border-bottom:1px solid var(--hair);flex:none;} .rail-h{display:flex;align-items:center;gap:7px;padding:9px 11px;border-bottom:1px solid var(--hair);flex:none;}
.rail-h .ttl{font-size:13.5px;font-weight:660;white-space:nowrap;} .rail-h .ttl{font-size:13.5px;font-weight:660;white-space:nowrap;}
@ -280,8 +284,22 @@ body.dock-r:not(.r-fold) #railR{display:flex;}
/* 窄条: 手动收起和宽度不够共用这一块 */ /* 窄条: 手动收起和宽度不够共用这一块 */
.strip{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px; .strip{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:10px;
width:var(--strip-w);min-height:150px;padding:14px 0;background:var(--surface); width:var(--strip-w);min-height:150px;padding:14px 0;background:var(--surface);
border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);cursor:pointer;} border:1px solid var(--hair);border-radius:14px;box-shadow:var(--shadow);cursor:pointer;
touch-action:manipulation;user-select:none;-webkit-user-select:none;
-webkit-tap-highlight-color:rgba(42,120,214,.18);}
.strip:hover{border-color:var(--accent);} .strip:hover{border-color:var(--accent);}
/* 触屏端 (iPad 等): 去掉点按歧义等待、命中区加大。鼠标端 (pointer:fine) 完全不受影响 */
.iconbtn,.railtabs button,.lv-chip,.rail-scrim,.rail-h{touch-action:manipulation;}
@media (pointer:coarse){
:root{ --strip-w:48px; }
.strip{min-height:176px;}
.iconbtn{width:30px;height:30px;font-size:14px;}
}
/* 无悬停设备 (触屏): ? 号的悬停气泡会在点按后粘住不消失 (iPad 截图里那块黑浮块),
干脆不出气泡 —— 它只是补充说明, 正文都能看到 */
@media (hover:none){
.help:hover::after{display:none;}
}
body.dock-l:not(.l-fold) .side-l .strip{display:none;} body.dock-l:not(.l-fold) .side-l .strip{display:none;}
body.dock-r:not(.r-fold) .side-r .strip{display:none;} body.dock-r:not(.r-fold) .side-r .strip{display:none;}
.strip .vtxt{writing-mode:vertical-rl;font-size:12.5px;font-weight:620;color:var(--ink-2);letter-spacing:2px;white-space:nowrap;} .strip .vtxt{writing-mode:vertical-rl;font-size:12.5px;font-weight:620;color:var(--ink-2);letter-spacing:2px;white-space:nowrap;}
@ -324,6 +342,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
.sigrow2 .sp{flex:1;} .sigrow2 .sp{flex:1;}
.sigrow2.old{opacity:.5;} .sigrow2.old{opacity:.5;}
.pin{font-size:10px;font-weight:700;color:var(--stop);background:var(--stop-weak);border-radius:4px;padding:0 4px;flex:none;} .pin{font-size:10px;font-weight:700;color:var(--stop);background:var(--stop-weak);border-radius:4px;padding:0 4px;flex:none;}
.pin.plan{color:var(--accent);background:var(--accent-weak);}
.srcgroup{border:1px solid var(--hair);border-radius:10px;margin-bottom:7px;overflow:hidden;background:var(--surface-2);} .srcgroup{border:1px solid var(--hair);border-radius:10px;margin-bottom:7px;overflow:hidden;background:var(--surface-2);}
.srch{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer; .srch{display:flex;align-items:center;gap:6px;padding:7px 10px;cursor:pointer;
font-size:12.5px;font-weight:600;color:var(--ink-2);} font-size:12.5px;font-weight:600;color:var(--ink-2);}
@ -519,17 +538,28 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
</div> </div>
<div class="rail-scrim" :class="{on:!!railOpen}" @click="closeRail"></div> <teleport to="body">
<div class="rail-scrim" :class="{on:!!railOpen}" @click="closeRail" @touchend="scrimTE"></div>
<!-- 远程排查小牌: 地址栏加 ?uidbg=1 才出现, 显示浏览器与布局关键量, 只读 -->
<div v-if="uidbg" class="mono" style="position:fixed;left:8px;bottom:8px;z-index:99990;
background:#141412;color:#fff;font-size:11px;line-height:1.6;padding:6px 9px;
border-radius:8px;max-width:86vw;opacity:.88;pointer-events:none">{{ dbgText }}</div>
</teleport>
<!-- ══════════ 三栏 ══════════ --> <!-- ══════════ 三栏 ══════════ -->
<div class="shell"> <div class="shell">
<!-- ─── 左栏: 信号流 ─── --> <!-- ─── 左栏: 信号流 ─── -->
<div class="side side-l"> <div class="side side-l">
<div class="strip" @click="openRailSide('l')" title="展开信号流"> <div class="strip" @click="openRailSide('l')" @touchstart.passive="railTS" @touchend="railTE($event,'l')" title="展开信号流">
<span class="badge" v-if="sigBriefHeldCount">{{ sigBriefHeldCount }}</span> <span class="badge" v-if="sigBriefHeldCount">{{ sigBriefHeldCount }}</span>
<span class="vtxt">信号流</span><span class="arr"></span> <span class="vtxt">信号流</span><span class="arr"></span>
</div> </div>
<!-- teleport (2026-09-01): 打开成浮层时把面板挂到 body 下再定位。side 是
position:sticky, 自成层叠上下文, 固定定位的浮层被困在里面时, 在部分
Safari 上会与主区表格的浮动列互相穿插 (iPad 实锅)。挂到 body 就没有
任何祖先能困住它; 常驻停靠时 disabled, 原地不动, 行为与从前逐字节相同 -->
<teleport to="body" :disabled="railOpen!=='l'">
<aside class="rail" id="railL" :class="{open:railOpen==='l'}"> <aside class="rail" id="railL" :class="{open:railOpen==='l'}">
<div class="rail-h"> <div class="rail-h">
<span class="ttl">信号流</span> <span class="ttl">信号流</span>
@ -567,9 +597,10 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
<div class="muted" v-if="sigBriefOther.length" <div class="muted" v-if="sigBriefOther.length"
style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px"> style="margin:10px 0 5px;font-weight:600;font-size:11.5px;border-top:1px dashed var(--hair);padding-top:8px">
没持仓的票 · 只列重要和警告</div> 没持仓的票 · 只列重要和警告 · 带「计划」的在今天的选股计划里</div>
<div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}"> <div v-for="(g,i) in sigBriefOther.slice(0,60)" :key="'o'+i" class="sigrow2" :class="{old:g.stale}">
<span class="t mono">{{ hm(g.time) || '—' }}</span> <span class="t mono">{{ hm(g.time) || '—' }}</span>
<span class="pin plan" v-if="g.plan">计划</span>
<span class="nm">{{ nm(g.ts_code) }}</span> <span class="nm">{{ nm(g.ts_code) }}</span>
<span class="cat">{{ g.cat }}</span> <span class="cat">{{ g.cat }}</span>
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span> <span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
@ -586,6 +617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<div v-for="(g,i) in sigOld.slice(0,60)" :key="'d'+i" class="sigrow2 old"> <div v-for="(g,i) in sigOld.slice(0,60)" :key="'d'+i" class="sigrow2 old">
<span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span> <span class="t mono" style="width:auto">{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}</span>
<span class="pin" v-if="g.held">持仓</span> <span class="pin" v-if="g.held">持仓</span>
<span class="pin plan" v-if="g.plan && !g.held">计划</span>
<span class="nm">{{ nm(g.ts_code) }}</span> <span class="nm">{{ nm(g.ts_code) }}</span>
<span class="cat">{{ g.cat }}</span> <span class="cat">{{ g.cat }}</span>
<span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span> <span v-if="g.direction" :class="dirCls(g.direction)">{{ g.direction }}</span>
@ -752,6 +784,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</template> </template>
</div> </div>
</aside> </aside>
</teleport>
</div> </div>
<!-- ─── 中区: 一览 / 需要注意 / 我的持仓 / 今日在办 ─── --> <!-- ─── 中区: 一览 / 需要注意 / 我的持仓 / 今日在办 ─── -->
@ -952,10 +985,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<!-- ─── 右栏: 待办与操作 ─── --> <!-- ─── 右栏: 待办与操作 ─── -->
<div class="side side-r"> <div class="side side-r">
<div class="strip" @click="openRailSide('r')" title="展开待办与操作"> <div class="strip" @click="openRailSide('r')" @touchstart.passive="railTS" @touchend="railTE($event,'r')" title="展开待办与操作">
<span class="arr"></span><span class="vtxt">待办与操作</span> <span class="arr"></span><span class="vtxt">待办与操作</span>
<span class="badge" v-if="proposals.length">{{ proposals.length }}</span> <span class="badge" v-if="proposals.length">{{ proposals.length }}</span>
</div> </div>
<teleport to="body" :disabled="railOpen!=='r'">
<aside class="rail" id="railR" :class="{open:railOpen==='r'}"> <aside class="rail" id="railR" :class="{open:railOpen==='r'}">
<div class="rail-h"> <div class="rail-h">
<button class="iconbtn btn-close" title="关闭(或按 Esc" @click="closeRail"></button> <button class="iconbtn btn-close" title="关闭(或按 Esc" @click="closeRail"></button>
@ -1183,6 +1217,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
</div> </div>
</aside> </aside>
</teleport>
</div> </div>
</div> </div>
@ -1748,6 +1783,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
<el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button> <el-button @click="ops('report')" :loading="opsLoading">生成日报</el-button>
<el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button> <el-button @click="loadSchema" :loading="opsLoading">导出下游表结构</el-button>
</div> </div>
<div class="row" style="margin-top:8px" v-if="(wsc||{}).resync_required">
<el-button type="warning" @click="clearResync" :loading="opsLoading">清除通道对账标记</el-button>
<span class="muted" style="flex:1;min-width:240px">通道曾经补发不全(顶部横幅「通道要人工做一次
全量对账」就是它)。处理顺序:先点上面的「账本对账」,确认结果里差异为零,再点这里清除提醒。</span>
</div>
<div class="muted" style="margin-top:10px"> <div class="muted" style="margin-top:10px">
当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }} 当前下发通道: <b>{{ dm.mode }}</b> —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换) (通道在「参数设置 → PMS_DISPATCH_MODE」切换)
@ -2658,6 +2698,9 @@ createApp({
// 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ── // 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ──
const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0]; const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0];
const heldCodeSet = computed(() => new Set(heldPositions.value.map(p => code6(p.ts_code)))); const heldCodeSet = computed(() => new Set(heldPositions.value.map(p => code6(p.ts_code))));
// 今天选股计划里的票 (上游 /plan 主榜, 登录时随 loadPlan 加载): 速览里没持仓的
// 信号若命中计划, 挂「计划」标 —— 交易员一眼分清哪些噪音其实是候选池的动静 (2026-08-31)
const planCodeSet = computed(() => new Set((planRows.value || []).map(r => code6(r.ts_code))));
const sigBrief = computed(() => { const sigBrief = computed(() => {
const held = heldCodeSet.value; const held = heldCodeSet.value;
const out = []; const out = [];
@ -2679,6 +2722,8 @@ createApp({
for (const r of (s.timing || [])) out.push({ for (const r of (s.timing || [])) out.push({
ts_code: r.ts_code, time: r.time || '', cat: '买入时机', ts_code: r.ts_code, time: r.time || '', cat: '买入时机',
direction: '买入', level: 'INFO', held: held.has(code6(r.ts_code)) }); direction: '买入', level: 'INFO', held: held.has(code6(r.ts_code)) });
const inPlan = planCodeSet.value;
for (const g of out) g.plan = inPlan.has(code6(g.ts_code));
out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time))); out.sort((a, b) => (b.held - a.held) || String(b.time).localeCompare(String(a.time)));
return out; return out;
}); });
@ -3004,6 +3049,21 @@ createApp({
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false; opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
await loadAll(); await loadAll();
} }
// 清通道对账标记 (2026-08-31): 后端接口早就有 (POST /api/ws-channel/clear-resync),
// 页面一直没有入口, 横幅让人"经运维抽屉清除"却无处可点。语义见后端注释: 标记只能
// 人清, 清之前必须先做完全量对账 (上面「账本对账」按钮) 且差异为零。
async function clearResync() {
try {
await ElementPlus.ElMessageBox.confirm(
'清除前请确认两件事:一,已点过「账本对账」;二,对账结果 diffs 为空(差异为零)。'
+ '清除只是去掉「通道要人工做一次全量对账」的提醒,不改任何账目。',
'清除通道对账标记', { type: 'warning', confirmButtonText: '已对账,清除', cancelButtonText: '先不清' });
} catch (e) { return; }
opsLoading.value = true;
const d = await call('post', '/api/ws-channel/clear-resync');
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
await Promise.all([loadWs(), loadAll()]);
}
async function loadSchema() { async function loadSchema() {
opsLoading.value = true; opsLoading.value = true;
const d = await call('get', '/api/ops/downstream-schema'); const d = await call('get', '/api/ops/downstream-schema');
@ -3202,10 +3262,22 @@ createApp({
document.body.classList.toggle('l-fold', lFold.value); document.body.classList.toggle('l-fold', lFold.value);
document.body.classList.toggle('r-fold', rFold.value); document.body.classList.toggle('r-fold', rFold.value);
} }
// ?uidbg=1 远程排查 (2026-09-01): iPad 只能靠截图排样式问题, 这块小牌把浏览器
// 版本与布局关键量直接印在屏幕上, 一张截图就够定位。平时不带参数, 完全不出现。
const uidbg = new URLSearchParams(location.search).has('uidbg');
const dbgText = ref('');
function _dbgUpdate() {
if (!uidbg) return;
dbgText.value = navigator.userAgent + ' · 视口 ' + window.innerWidth + '×' + window.innerHeight
+ ' · dock-l ' + document.body.classList.contains('dock-l')
+ ' · dock-r ' + document.body.classList.contains('dock-r')
+ ' · 浮层 ' + (railOpen.value || '关');
}
function applyDock() { function applyDock() {
const w = window.innerWidth; const w = window.innerWidth;
document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT); document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT);
document.body.classList.toggle('dock-r', w >= W_DOCK_BOTH); document.body.classList.toggle('dock-r', w >= W_DOCK_BOTH);
_dbgUpdate();
// 这一侧已经能常驻了, 就不该还浮在上面 // 这一侧已经能常驻了, 就不该还浮在上面
if (railOpen.value === 'l' && w >= W_DOCK_LEFT && !lFold.value) railOpen.value = null; if (railOpen.value === 'l' && w >= W_DOCK_LEFT && !lFold.value) railOpen.value = null;
if (railOpen.value === 'r' && w >= W_DOCK_BOTH && !rFold.value) railOpen.value = null; if (railOpen.value === 'r' && w >= W_DOCK_BOTH && !rFold.value) railOpen.value = null;
@ -3214,7 +3286,7 @@ createApp({
if (side === 'l') lFold.value = true; else rFold.value = true; if (side === 'l') lFold.value = true; else rFold.value = true;
_railClasses(); _railSave(); _railClasses(); _railSave();
} }
function closeRail() { railOpen.value = null; } function closeRail() { railOpen.value = null; _dbgUpdate(); }
function openRailSide(side, forcePull) { function openRailSide(side, forcePull) {
const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r'); const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r');
const folded = side === 'l' ? lFold.value : rFold.value; const folded = side === 'l' ? lFold.value : rFold.value;
@ -3225,7 +3297,24 @@ createApp({
} }
if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层 if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层
railOpen.value = side; railOpen.value = side;
_dbgUpdate();
} }
// 触屏直连 (2026-08-31): iPad 的 Safari 对非控件元素的点按并不总是合成 click 事件,
// 窄条在触屏上点不开就是这么来的。touchend 直接开侧栏, preventDefault 挡掉后续可能
// 合成的 click 免得双触发; 记录起点加位移阈值, 挡住"滑动页面时扫过窄条"的误触。
// 鼠标端没有 touch 事件, 走原来的 @click, 行为一个字都不变。
const _tp = { x: 0, y: 0 };
function railTS(e) {
const p = (e.touches && e.touches[0]) || {};
_tp.x = p.clientX || 0; _tp.y = p.clientY || 0;
}
function railTE(e, side) {
const p = (e.changedTouches && e.changedTouches[0]) || {};
if (Math.hypot((p.clientX || 0) - _tp.x, (p.clientY || 0) - _tp.y) > 12) return;
e.preventDefault();
openRailSide(side);
}
function scrimTE(e) { e.preventDefault(); closeRail(); }
function _onEsc(e) { if (e.key === 'Escape') closeRail(); } function _onEsc(e) { if (e.key === 'Escape') closeRail(); }
onMounted(() => { onMounted(() => {
_railClasses(); applyDock(); _railClasses(); applyDock();
@ -3274,7 +3363,8 @@ createApp({
MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown, MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown,
msgLatest, msgLevelClass, setMsgKind, msgGo, msgLatest, msgLevelClass, setMsgKind, msgGo,
sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther, sigTab, srcOpen, toggleSrc, sigBriefHeld, sigBriefOther,
lFold, rFold, railOpen, foldRail, closeRail, openRailSide, lFold, rFold, railOpen, foldRail, closeRail, openRailSide, railTS, railTE, scrimTE,
clearResync, uidbg, dbgText,
authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport, authed, authReady, me, isAdmin, rolesText, loginForm, loginBusy, loginErr, doLogin, doLogout, doExport,
sectClosed, toggleSect }; sectClosed, toggleSect };
} }

View File

@ -79,7 +79,9 @@ class WsRunner:
self._acked_seq = 0 # 已发出 ack_seq 的水位 self._acked_seq = 0 # 已发出 ack_seq 的水位
self._pending_seq = set() # 乱序暂存 (正常恒空) self._pending_seq = set() # 乱序暂存 (正常恒空)
self._unacked = 0 # 距上次 ack 又落了几条 self._unacked = 0 # 距上次 ack 又落了几条
self._baselined = False # 是否已对齐对端序号起点 (见 _set_baseline) self._baselined = False # 本连接是否已对齐对端序号起点 (见 _set_baseline;
# 每次连接在 _session 里重置, 2026-08-31)
self._gap_stuck = None # (卡住时的水位, 卡住起始时刻) —— 弃洞watchdog用
self._db_ready = False # 通道三表是否可用 (缺表时空转重试, 不写心跳) self._db_ready = False # 通道三表是否可用 (缺表时空转重试, 不写心跳)
self._params = {} self._params = {}
self._warn = "" # 握手期发现的非致命异常, 连上后仍要挂在 last_error self._warn = "" # 握手期发现的非致命异常, 连上后仍要挂在 last_error
@ -107,6 +109,7 @@ class WsRunner:
"max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3), "max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3),
"connect_timeout": param_store.get_int("PMS_QMT_CONNECT_TIMEOUT_SEC", 10), "connect_timeout": param_store.get_int("PMS_QMT_CONNECT_TIMEOUT_SEC", 10),
"query_interval_sec": param_store.get_int("PMS_QMT_QUERY_INTERVAL_SEC", 300), "query_interval_sec": param_store.get_int("PMS_QMT_QUERY_INTERVAL_SEC", 300),
"gap_skip_sec": param_store.get_int("PMS_QMT_GAP_SKIP_SEC", 300),
} }
try: try:
self._params = await _db(_load) self._params = await _db(_load)
@ -117,7 +120,7 @@ class WsRunner:
"idle_timeout_sec": 15, "ack_batch": 20, "idle_timeout_sec": 15, "ack_batch": 20,
"ack_interval_sec": 2.0, "outbox_poll_sec": 0.5, "ack_interval_sec": 2.0, "outbox_poll_sec": 0.5,
"beat_sec": 2, "max_attempts": 3, "connect_timeout": 10, "beat_sec": 2, "max_attempts": 3, "connect_timeout": 10,
"query_interval_sec": 300} "query_interval_sec": 300, "gap_skip_sec": 300}
logger.warning("参数刷新失败, 沿用上一份: %s", _brief_err(e)) logger.warning("参数刷新失败, 沿用上一份: %s", _brief_err(e))
@staticmethod @staticmethod
@ -292,6 +295,13 @@ class WsRunner:
self._stat.pop("ack_seq_degraded", None) self._stat.pop("ack_seq_degraded", None)
for k in ("peer_skew_min", "peer_skew_max"): for k in ("peer_skew_min", "peer_skew_max"):
self._stat.pop(k, None) # 时钟差/卡顿按会话统计, 跨连接混着看没意义 self._stat.pop(k, None) # 时钟差/卡顿按会话统计, 跨连接混着看没意义
# 序号基线每个连接都重新对齐一次 (2026-08-31): 原来 _baselined 一经置位终生
# 不再对齐, 会话中途出的洞 (对端跳号/上行验签失败被本端丢弃) 补发也补不出来,
# 水位就永远卡在洞前 —— 真机卡了三天、暂存五万三千条就是这么来的。重连后首条
# 补发消息与水位连续时 _set_baseline 是空操作, 行为与从前逐字节相同; 不连续
# 说明那段真拿不到了, 推基线 + 置 resync 走全量对账, 与冷启动同一条 §6.2 路。
self._baselined = False
self._gap_stuck = None
await self._handshake(seed, peer) await self._handshake(seed, peer)
# last_error 用握手期攒下的告警覆盖: 连上了不等于没问题 (见 _handshake # last_error 用握手期攒下的告警覆盖: 连上了不等于没问题 (见 _handshake
# 的序号倒挂检查), 一律清空会把唯一一条线索抹掉。 # 的序号倒挂检查), 一律清空会把唯一一条线索抹掉。
@ -416,6 +426,9 @@ class WsRunner:
except wsc.CodecError as e: except wsc.CodecError as e:
# §2.1: 验签失败直接丢弃, **不执行任何业务动作**。不回 reject —— # §2.1: 验签失败直接丢弃, **不执行任何业务动作**。不回 reject ——
# 连不上信任的对端时, 多说一句话只是多给攻击者一个探测面。 # 连不上信任的对端时, 多说一句话只是多给攻击者一个探测面。
# 计数挂 stat (2026-08-31): 被丢的消息若带 seq, 水位上就多一个洞,
# 这个数是排「水位为什么卡住」时的第一线索, 光靠翻日志太难受。
self._stat["sig_invalid"] = self._stat.get("sig_invalid", 0) + 1
logger.error("上行消息校验失败, 已丢弃 (%s): %s", e.code, e.message) logger.error("上行消息校验失败, 已丢弃 (%s): %s", e.code, e.message)
continue continue
self._track_skew(env) self._track_skew(env)
@ -525,17 +538,58 @@ class WsRunner:
# 明确收尾 —— 有始有终才看得出它到底有没有卡住。 # 明确收尾 —— 有始有终才看得出它到底有没有卡住。
self._stat["ooo"] = self._stat.get("ooo", 0) + 1 self._stat["ooo"] = self._stat.get("ooo", 0) + 1
n, k = self._stat["ooo"], len(self._pending_seq) n, k = self._stat["ooo"], len(self._pending_seq)
self._stat["pending_gap"] = k
if n == 1 or n % 200 == 0: if n == 1 or n % 200 == 0:
logger.warning("水位未连续: 卡在 %s, 暂存 %s 条 (第 %s 次)。刚重连的话这是 " logger.warning("水位未连续: 卡在 %s, 暂存 %s 条 (第 %s 次)。刚重连的话这是 "
"§6.1 补发的正常形态, 补完自会收敛; 若暂存条数持续增长, " "§6.1 补发的正常形态, 补完自会收敛; 若暂存条数持续增长, "
"才是真乱序", self._last_seq, k, n) "才是真乱序", self._last_seq, k, n)
await self._maybe_skip_gap()
elif had_pending: elif had_pending:
logger.info("水位缺口已补齐, 连续到 %s (期间未连续 %s 次)", logger.info("水位缺口已补齐, 连续到 %s (期间未连续 %s 次)",
self._last_seq, self._stat.get("ooo", 0)) self._last_seq, self._stat.get("ooo", 0))
self._stat["ooo"] = 0 self._stat["ooo"] = 0
self._stat["pending_gap"] = 0
self._gap_stuck = None
if self._unacked >= self._p("ack_batch", 20): if self._unacked >= self._p("ack_batch", 20):
await self._flush_ack() await self._flush_ack()
async def _maybe_skip_gap(self):
"""水位在同一位置卡满 gap_skip_sec 秒 → 按 §6.2 弃洞前进 (2026-08-31)。
ws 是单连接有序流: 会话内出现的洞 (对端跳号 / 上行验签失败被本端丢弃) 在本会话里
**永远**等不来, §6.1 补发只发生在重连握手时; 而对端日志滚动后连重连也补不出来
原来的处置是干等 暂存集合无限涨ack 水位停摆每次重连对端整段重发 (真机卡了
三天, 暂存五万三千条)现在等满窗口就认定补不出来, _set_baseline 处置真缺口
同一语义: 推水位到已到达的最高序号清暂存 resync 标记走全量对账
暂存里的序号全部来自**验签通过**的消息, 推进目标不受伪造数据影响
窗口在参数中心调 (PMS_QMT_GAP_SKIP_SEC, 默认 300 ; 0 = 关掉本机制)
"""
skip_after = self._p("gap_skip_sec", 300)
if skip_after <= 0 or not self._pending_seq:
return
now = time.monotonic()
if self._gap_stuck is None or self._gap_stuck[0] != self._last_seq:
self._gap_stuck = (self._last_seq, now) # 水位动过 → 重新计时
return
if now - self._gap_stuck[1] < skip_after:
return
old, waited = self._last_seq, len(self._pending_seq)
self._last_seq, missing = wsc.skip_gap(self._last_seq, self._pending_seq)
self._pending_seq = set()
self._acked_seq = max(self._acked_seq, self._last_seq)
self._gap_stuck = None
self._stat["gap_skips"] = self._stat.get("gap_skips", 0) + 1
self._stat["ooo"] = 0
self._stat["pending_gap"] = 0
with contextlib.suppress(Exception):
await _db(qmt_repo.save_watermark, self._last_seq, self._acked_seq)
with contextlib.suppress(Exception):
await _db(qmt_repo.set_conn, "ONLINE", resync=True)
logger.error("水位弃洞前进 (§6.2): 卡在 %s 已满 %s 秒, 暂存 %s 条早已到齐而中间 "
"%s 个序号等不来 —— 水位推到 %s。已置 resync 标记: 请做一次全量对账 "
"(运维「账本对账」, 差异清零), 然后在运维抽屉点「清除通道对账标记」",
old, skip_after, waited, missing, self._last_seq)
def _maybe_degrade_ack(self, pl: dict): def _maybe_degrade_ack(self, pl: dict):
"""对端回「不认识 ack_seq」时停发 ack_seq。 """对端回「不认识 ack_seq」时停发 ack_seq。

View File

@ -188,7 +188,10 @@ CREATE TABLE IF NOT EXISTS pms_industry_map (
-- 10. 运行参数持久层 (页面修改的参数落此, 优先于 settings 文件初值) -- 10. 运行参数持久层 (页面修改的参数落此, 优先于 settings 文件初值)
CREATE TABLE IF NOT EXISTS pms_runtime_param ( CREATE TABLE IF NOT EXISTS pms_runtime_param (
param_key VARCHAR(64) PRIMARY KEY, param_key VARCHAR(64) PRIMARY KEY,
param_value VARCHAR(200) NOT NULL, -- TEXT 而非 VARCHAR(200) (2026-08-31 放宽): 清场名单 PMS_EXIT_CLEANUP_DONE 与回放游标
-- PMS_REPLAY_CURSOR 都是会随运行长大的 JSON, 30 只闭仓票的名单就超过 200 字符,
-- 写入每分钟报 1406 截断错。已有库用 scripts/migrate_param_value_text.py 迁移。
param_value TEXT NOT NULL,
updated_by VARCHAR(32) NOT NULL DEFAULT 'user', updated_by VARCHAR(32) NOT NULL DEFAULT 'user',
updated_at DATETIME NOT NULL updated_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值'; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值';

View File

@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""
迁移: pms_runtime_param.param_value VARCHAR(200) 放宽为 TEXT
================================================================
运行:
docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py # 演练, 只打印
docker compose run --rm --no-deps pms-web python scripts/migrate_param_value_text.py --yes # 实际执行
为什么要迁:
这一列存的不全是短标量 清场名单 (PMS_EXIT_CLEANUP_DONE, 闭仓票代码的 JSON 数组)
回放游标 (PMS_REPLAY_CURSOR, v2 起带 seen 去重字典) 都随运行越长越大真机 2026-08-31
实测: 名单 30 只票就超 200 字符, 两处写入每分钟各报一次 1406 截断错, 游标推不动
清场防呆标记存不上 (后者失守时, 闭仓后新下的建仓单会被清场误撤)
为什么单独一个脚本, 不放进 init_db:
init_db.py 只认 `CREATE TABLE IF NOT EXISTS` 与幂等 INSERT, ALTER 语句会被它判成残句
整批中止; CREATE IF NOT EXISTS 对已存在的表一个字都不改建表语句 (ddl_pms_v1.sql)
已同步改成 TEXT, **新库直接就有**, 不需要跑本脚本
幂等: 列已是 TEXT 时自动跳过, 重复跑安全绕过 db.session 的单表守卫 (直接用引擎),
migrate_archived_at 同一套做法 ALTER ShardingSphere-Proxy 拒绝, 脚本会把完整
语句打出来, 直接拿去物理库 (my_quant_db) 执行即可
"""
import argparse
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
TABLE = "pms_runtime_param"
COL = "param_value"
TARGET_TYPE = "text"
def _col_type(conn, table: str, col: str):
from sqlalchemy import text
rows = conn.execute(text(f"SHOW COLUMNS FROM {table}")).fetchall()
for r in rows:
if r[0] == col:
return str(r[1] or "").lower()
return None
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--yes", action="store_true", help="确认执行 (缺省只演练)")
args = ap.parse_args()
from sqlalchemy import text
from app.db.session import TABLE_PREFIX, get_engine, map_tables
eng = get_engine("proxy")
if TABLE_PREFIX:
print(f"[双实例] 表名前缀 {TABLE_PREFIX!r} 生效: 实际迁移的是 {TABLE_PREFIX}{TABLE}")
real_table = map_tables(TABLE)
try:
with eng.connect() as c:
cur = _col_type(c, real_table, COL)
except Exception as e:
print(f"FAIL: 查列失败 {type(e).__name__}: {e} (表不存在就先跑 init_db)")
sys.exit(1)
if cur is None:
print(f"FAIL: {real_table} 没有 {COL} 列 —— 表结构与预期不符, 先人工核对")
sys.exit(1)
print(f"当前类型: {real_table}.{COL} = {cur}")
if cur.startswith(TARGET_TYPE):
print("无需迁移: 已是 TEXT。")
return
stmt = map_tables(
f"ALTER TABLE {TABLE} MODIFY COLUMN {COL} TEXT NOT NULL "
f"COMMENT '参数值: 标量或 JSON; 2026-08-31 由 VARCHAR(200) 放宽 (清场名单/回放游标会长大)'")
if not args.yes:
print("\n[演练模式] 未执行任何语句。确认无误后加 --yes 重跑。将执行:")
print(" " + stmt)
return
try:
with eng.begin() as c:
c.execute(text(stmt))
print("OK ALTER 执行成功")
except Exception as e:
print(f"FAIL {type(e).__name__}: {e}")
print(f"\n完整语句如下 —— 可直接拿到物理库 (my_quant_db) 执行:\n{stmt};")
sys.exit(1)
with eng.connect() as c:
now = _col_type(c, real_table, COL)
if now and now.startswith(TARGET_TYPE):
print(f"ALL OK: {real_table}.{COL} 已是 {now}。清场名单与回放游标的写入即刻恢复, 无需重启。")
else:
print(f"FAIL: 迁移后类型仍为 {now}")
sys.exit(1)
if __name__ == "__main__":
main()

View File

@ -10,7 +10,7 @@
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (21 ) test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (21 )
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 ) test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 )
test_batch5_units.py 决策系统信号流解析与消化口径 (8 ) test_batch5_units.py 决策系统信号流解析与消化口径 (8 )
test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/DDL/逐笔入账 (65 ) test_batch6_units.py ws 通道: 测试向量/签名/公钥/水位/弃洞/DDL/逐笔入账 (68 )
test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 ) test_batch7_units.py 上游选股计划: 解析/新鲜度/候选筛选/取数守卫 (32 )
test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 ) test_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 )
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 ) test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 )
@ -39,7 +39,7 @@
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停 缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 ) 按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 )
test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (67 ) test_wiring.py 装配自检: 服务层核心落表 全链路 (内存桩) (67 )
588 591
任一子集失败即整体失败 (退出码 1) 任一子集失败即整体失败 (退出码 1)
""" """
import os import os

View File

@ -441,7 +441,8 @@ def _():
# init_db / migrate_archived_at 拿 get_engine 直执行 DDL (有意绕过单表守卫), # init_db / migrate_archived_at 拿 get_engine 直执行 DDL (有意绕过单表守卫),
# 它们必须显式调 map_tables —— 否则正式实例 make deploy 会建出**无前缀**的表, # 它们必须显式调 map_tables —— 否则正式实例 make deploy 会建出**无前缀**的表,
# 与模拟实例的表撞在一起 # 与模拟实例的表撞在一起
for f in ("scripts/init_db.py", "scripts/migrate_archived_at.py"): for f in ("scripts/init_db.py", "scripts/migrate_archived_at.py",
"scripts/migrate_param_value_text.py"):
src = open(os.path.join(root, f), encoding="utf-8").read() src = open(os.path.join(root, f), encoding="utf-8").read()
assert "map_tables" in src, f + " 直执 DDL 必须过 map_tables" assert "map_tables" in src, f + " 直执 DDL 必须过 map_tables"

View File

@ -340,6 +340,26 @@ def run():
last, pend = wsc.next_watermark(last, pend, s) last, pend = wsc.next_watermark(last, pend, s)
eq((last, pend), (10003, set())) eq((last, pend), (10003, set()))
# --- 弃洞前进。2026-08-31 真机缺陷的回归防线: 会话中途 99 条验签失败的 pong 被丢,
# 每条占一个 seq → 水位卡在洞前三天、暂存五万三千条。ws 是有序流, 会话内的洞
# 等不来 (§6.1 补发只在重连握手时), 到点就按 §6.2 弃洞: 推水位、清暂存、置 resync。
@case("弃洞前进: 推到已到达的最高序号, 洞数点得清")
def _():
# 卡在 100, 暂存 {102,103,105}: 101 与 104 是洞
eq(wsc.skip_gap(100, {102, 103, 105}), (105, 2))
@case("弃洞前进: 暂存为空原地不动; 全连续时洞数为 0")
def _():
eq(wsc.skip_gap(100, set()), (100, 0))
eq(wsc.skip_gap(100, {101, 102}), (102, 0))
@case("弃洞前进后水位能正常前进 (与 next_watermark 接得上)")
def _():
last, _n = wsc.skip_gap(365003, {365100, 365101, 421943})
eq(last, 421943)
last, pend = wsc.next_watermark(last, set(), 421944)
eq((last, pend), (421944, set()))
print("\n[F] 去重键与成交自洽") print("\n[F] 去重键与成交自洽")
@case("trade 的第二层去重键取 trade_no") @case("trade 的第二层去重键取 trade_no")

View File

@ -146,7 +146,18 @@ def cmd_status(args):
f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 " f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 "
f"processed 改回 0") f"processed 改回 0")
if ch.get("resync_required"): if ch.get("resync_required"):
bad.append("resync_flag=1: 对端补发不全, 须走全量对账后经页面清除") bad.append("resync_flag=1: 对端补发不全, 须走全量对账后经页面清除 "
"(运维抽屉「账本对账」→ 差异清零 →「清除通道对账标记」)")
if int(s.get("sig_invalid") or 0):
bad.append(f"验签失败被丢弃 {s['sig_invalid']} 条: 每条若带 seq 就在水位上留一个洞。"
f"持续增长要与 QMT 侧核对签名实现 (哪类消息在坏, 见 docker compose "
f"logs pms-ws | grep SIG_INVALID)")
if int(s.get("pending_gap") or 0) > 500:
bad.append(f"乱序暂存 {s['pending_gap']} 条: 水位卡在洞前。等满 PMS_QMT_GAP_SKIP_SEC "
f"(默认 300 秒) 会自动弃洞前进并置 resync 标记")
if int(s.get("gap_skips") or 0):
bad.append(f"本进程已弃洞前进 {s['gap_skips']} 次: 每次都置了 resync 标记, "
f"记得全量对账后清除")
if bad: if bad:
print() print()
for b in bad: for b in bad:

Binary file not shown.