diff --git a/DEVLOG.md b/DEVLOG.md
index 54df35a..5eaf88e 100644
--- a/DEVLOG.md
+++ b/DEVLOG.md
@@ -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 → 次日从零自动建仓; 清账前先存档), 待拍板。
---
+
-
+
{{ sigBriefHeldCount }}
信号流▶
+
+
{{ hm(g.time) || '—' }}
+
计划
{{ nm(g.ts_code) }}
{{ g.cat }}
{{ g.direction }}
@@ -586,6 +617,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
{{ (g.ymd||'').slice(5) }} {{ hm(g.time) || '—' }}
持仓
+ 计划
{{ nm(g.ts_code) }}
{{ g.cat }}
{{ g.direction }}
@@ -752,6 +784,7 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
+
@@ -952,10 +985,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
-
+
◀待办与操作
{{ proposals.length }}
+
+
@@ -1748,6 +1783,11 @@ body.dock-r:not(.r-fold) .side-r .strip{display:none;}
生成日报
导出下游表结构
+
+ 清除通道对账标记
+ 通道曾经补发不全(顶部横幅「通道要人工做一次
+ 全量对账」就是它)。处理顺序:先点上面的「账本对账」,确认结果里差异为零,再点这里清除提醒。
+
当前下发通道: {{ dm.mode }} —— {{ dm.hint }}
(通道在「参数设置 → PMS_DISPATCH_MODE」切换)
@@ -2658,6 +2698,9 @@ createApp({
// 入场三路独立信号也并进来, 免得只看告警漏了真正会动手的那几条。 ──
const code6 = s => (String(s || '').match(/\d{6}/) || [''])[0];
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 held = heldCodeSet.value;
const out = [];
@@ -2679,6 +2722,8 @@ createApp({
for (const r of (s.timing || [])) out.push({
ts_code: r.ts_code, time: r.time || '', cat: '买入时机',
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)));
return out;
});
@@ -3004,6 +3049,21 @@ createApp({
opsResult.value = JSON.stringify(d, null, 2); opsLoading.value = false;
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() {
opsLoading.value = true;
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('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() {
const w = window.innerWidth;
document.body.classList.toggle('dock-l', w >= W_DOCK_LEFT);
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 === '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;
_railClasses(); _railSave();
}
- function closeRail() { railOpen.value = null; }
+ function closeRail() { railOpen.value = null; _dbgUpdate(); }
function openRailSide(side, forcePull) {
const canDock = document.body.classList.contains(side === 'l' ? 'dock-l' : 'dock-r');
const folded = side === 'l' ? lFold.value : rFold.value;
@@ -3225,7 +3297,24 @@ createApp({
}
if (!forcePull && canDock && !folded) return; // 已经常驻着, 不用再浮一层
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(); }
onMounted(() => {
_railClasses(); applyDock();
@@ -3274,7 +3363,8 @@ createApp({
MSG_KINDS, kindLabel, hm, msgOpen, msgKind, msgFeed, msgCounts, msgShown,
msgLatest, msgLevelClass, setMsgKind, msgGo,
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,
sectClosed, toggleSect };
}
diff --git a/app/ws/runner.py b/app/ws/runner.py
index 9f1424e..433a5f8 100644
--- a/app/ws/runner.py
+++ b/app/ws/runner.py
@@ -79,7 +79,9 @@ class WsRunner:
self._acked_seq = 0 # 已发出 ack_seq 的水位
self._pending_seq = set() # 乱序暂存 (正常恒空)
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._params = {}
self._warn = "" # 握手期发现的非致命异常, 连上后仍要挂在 last_error
@@ -107,6 +109,7 @@ class WsRunner:
"max_attempts": param_store.get_int("PMS_QMT_SEND_MAX_ATTEMPTS", 3),
"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),
+ "gap_skip_sec": param_store.get_int("PMS_QMT_GAP_SKIP_SEC", 300),
}
try:
self._params = await _db(_load)
@@ -117,7 +120,7 @@ class WsRunner:
"idle_timeout_sec": 15, "ack_batch": 20,
"ack_interval_sec": 2.0, "outbox_poll_sec": 0.5,
"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))
@staticmethod
@@ -292,6 +295,13 @@ class WsRunner:
self._stat.pop("ack_seq_degraded", None)
for k in ("peer_skew_min", "peer_skew_max"):
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)
# last_error 用握手期攒下的告警覆盖: 连上了不等于没问题 (见 _handshake
# 的序号倒挂检查), 一律清空会把唯一一条线索抹掉。
@@ -416,6 +426,9 @@ class WsRunner:
except wsc.CodecError as e:
# §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)
continue
self._track_skew(env)
@@ -525,17 +538,58 @@ class WsRunner:
# 明确收尾 —— 有始有终才看得出它到底有没有卡住。
self._stat["ooo"] = self._stat.get("ooo", 0) + 1
n, k = self._stat["ooo"], len(self._pending_seq)
+ self._stat["pending_gap"] = k
if n == 1 or n % 200 == 0:
logger.warning("水位未连续: 卡在 %s, 暂存 %s 条 (第 %s 次)。刚重连的话这是 "
"§6.1 补发的正常形态, 补完自会收敛; 若暂存条数持续增长, "
"才是真乱序", self._last_seq, k, n)
+ await self._maybe_skip_gap()
elif had_pending:
logger.info("水位缺口已补齐, 连续到 %s (期间未连续 %s 次)",
self._last_seq, self._stat.get("ooo", 0))
self._stat["ooo"] = 0
+ self._stat["pending_gap"] = 0
+ self._gap_stuck = None
if self._unacked >= self._p("ack_batch", 20):
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):
"""对端回「不认识 ack_seq」时停发 ack_seq。
diff --git a/ddl_pms_v1.sql b/ddl_pms_v1.sql
index 20f1673..4b5062d 100644
--- a/ddl_pms_v1.sql
+++ b/ddl_pms_v1.sql
@@ -188,7 +188,10 @@ CREATE TABLE IF NOT EXISTS pms_industry_map (
-- 10. 运行参数持久层 (页面修改的参数落此, 优先于 settings 文件初值)
CREATE TABLE IF NOT EXISTS pms_runtime_param (
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_at DATETIME NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='页面可调参数当前值';
diff --git a/scripts/migrate_param_value_text.py b/scripts/migrate_param_value_text.py
new file mode 100644
index 0000000..b9579f1
--- /dev/null
+++ b/scripts/migrate_param_value_text.py
@@ -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()
diff --git a/scripts/run_tests.py b/scripts/run_tests.py
index dac967c..60c4a60 100644
--- a/scripts/run_tests.py
+++ b/scripts/run_tests.py
@@ -10,7 +10,7 @@
test_batch3_units.py 规则闸 / 择时执行器实现B 纯逻辑 (21 例)
test_batch4_units.py 动作引擎 四类自主动作触发与数量口径 (11 例)
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_batch8_units.py 榜单变化: 名册指纹/三种语义/尾部闸/落库往返 (60 例)
test_batch9_units.py 成本价体检 / 对账按日推进 / 行业闸 / 取整记账 (49 例)
@@ -39,7 +39,7 @@
缩放/日历按年降级/网格中枢与止盈闩锁/买入暂停
按来源分记/宏观失败路径保留留痕/双实例表名前缀 (19 例)
test_wiring.py 装配自检: 服务层→核心→落表 全链路 (内存桩) (67 例)
- 共 588 例
+ 共 591 例
任一子集失败即整体失败 (退出码 1)。
"""
import os
diff --git a/scripts/test_batch19_units.py b/scripts/test_batch19_units.py
index 899ec3f..a4bcb6b 100644
--- a/scripts/test_batch19_units.py
+++ b/scripts/test_batch19_units.py
@@ -441,7 +441,8 @@ def _():
# init_db / migrate_archived_at 拿 get_engine 直执行 DDL (有意绕过单表守卫),
# 它们必须显式调 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()
assert "map_tables" in src, f + " 直执 DDL 必须过 map_tables"
diff --git a/scripts/test_batch6_units.py b/scripts/test_batch6_units.py
index 044cc80..f255e31 100644
--- a/scripts/test_batch6_units.py
+++ b/scripts/test_batch6_units.py
@@ -340,6 +340,26 @@ def run():
last, pend = wsc.next_watermark(last, pend, s)
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] 去重键与成交自洽")
@case("trade 的第二层去重键取 trade_no")
diff --git a/scripts/ws_smoke.py b/scripts/ws_smoke.py
index 70ca82e..9a9f98f 100644
--- a/scripts/ws_smoke.py
+++ b/scripts/ws_smoke.py
@@ -146,7 +146,18 @@ def cmd_status(args):
f"看清单: ws_smoke.py inbox --type trade; 确认真该入账再把那些行的 "
f"processed 改回 0")
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:
print()
for b in bad:
diff --git a/公司原表-量化数据2026.8.3.xlsx b/公司原表-量化数据2026.8.3.xlsx
new file mode 100644
index 0000000..99fbf84
Binary files /dev/null and b/公司原表-量化数据2026.8.3.xlsx differ