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 → 次日从零自动建仓; 清账前先存档), 待拍板。 --- +
{{ dbgText }}
+
-
+
{{ 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