20260928171336

This commit is contained in:
oneao committed 2026-09-28 17:13:37 +08:00
1 parent f22e67fc85
commit cbd49ded6f
34 files changed
+738 -7842

No files matched your search

+417
View File
@@ -0,0 +1,417 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Workflow 与 saveobjt 并发演示</title>
<style>
:root {
color-scheme: light;
--ink: #17202a;
--muted: #65727e;
--line: #d9e0e6;
--panel: #ffffff;
--bg: #f3f6f8;
--blue: #1769aa;
--blue-soft: #e7f1fa;
--green: #177245;
--green-soft: #e8f6ee;
--red: #b42318;
--red-soft: #fff0ee;
--amber: #9a6700;
--amber-soft: #fff7df;
}
* { box-sizing: border-box; }
body {
margin: 0;
background: var(--bg);
color: var(--ink);
font: 14px/1.55 system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
}
main { max-width: 1180px; margin: 0 auto; padding: 28px 20px 48px; }
h1, h2, h3 { margin: 0; line-height: 1.25; }
h1 { font-size: 26px; letter-spacing: 0; }
h2 { font-size: 17px; }
h3 { font-size: 14px; }
p { margin: 8px 0 0; }
.muted { color: var(--muted); }
.intro { margin-bottom: 18px; }
.intro strong { color: var(--blue); }
.grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(330px, .9fr); gap: 16px; }
.panel {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 8px;
padding: 18px;
box-shadow: 0 2px 8px rgba(28, 45, 58, .04);
}
.panel + .panel { margin-top: 16px; }
.panel-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.strategy { display: flex; flex-wrap: wrap; gap: 8px; margin: 12px 0 16px; }
button, select {
font: inherit;
border: 1px solid #b8c5cf;
border-radius: 6px;
background: #fff;
color: var(--ink);
min-height: 36px;
padding: 7px 11px;
cursor: pointer;
}
button:hover { border-color: var(--blue); color: var(--blue); }
button.primary { background: var(--blue); border-color: var(--blue); color: #fff; }
button.primary:hover { background: #125688; color: #fff; }
button.ghost { background: transparent; }
button.danger { color: var(--red); border-color: #e5aaa5; }
.strategy button.active { background: var(--blue-soft); border-color: var(--blue); color: var(--blue); font-weight: 650; }
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; }
.tables { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; border-bottom: 1px solid var(--line); padding: 8px 6px; vertical-align: top; }
th { color: var(--muted); font-weight: 600; background: #fafcfd; }
.status { display: inline-flex; align-items: center; border-radius: 999px; padding: 2px 8px; font-size: 12px; font-weight: 650; }
.status.pending, .status.running { background: var(--amber-soft); color: var(--amber); }
.status.approved, .status.completed { background: var(--green-soft); color: var(--green); }
.status.failed { background: var(--red-soft); color: var(--red); }
.snapshot-list { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.snapshot { border: 1px solid var(--line); border-radius: 6px; padding: 10px; background: #fbfcfd; }
.snapshot.loaded { border-color: #87bce2; background: var(--blue-soft); }
.snapshot .label { display: flex; justify-content: space-between; gap: 8px; font-weight: 650; }
.snapshot code { display: block; margin-top: 7px; color: #3c4a56; white-space: pre-wrap; }
pre {
overflow: auto;
margin: 0;
background: #17202a;
color: #e7eef4;
border-radius: 6px;
padding: 12px;
font: 12px/1.5 ui-monospace, SFMono-Regular, Consolas, monospace;
min-height: 120px;
}
.log { max-height: 300px; overflow: auto; display: grid; gap: 7px; }
.log-entry { border-left: 3px solid #b8c5cf; padding: 5px 9px; background: #fafcfd; }
.log-entry.ok { border-color: var(--green); }
.log-entry.bad { border-color: var(--red); background: var(--red-soft); }
.log-entry.note { border-color: var(--blue); background: var(--blue-soft); }
.callout { border-left: 4px solid var(--blue); background: var(--blue-soft); padding: 10px 12px; margin: 12px 0; }
.callout.warn { border-color: var(--amber); background: var(--amber-soft); }
.legend { display: flex; flex-wrap: wrap; gap: 12px; color: var(--muted); font-size: 12px; }
.legend span::before { content: ""; display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; background: currentColor; }
.footer-note { margin-top: 16px; color: var(--muted); font-size: 12px; }
@media (max-width: 850px) {
.grid, .tables { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<main>
<section class="intro">
<h1>Workflow 与 saveobjt 并发演示</h1>
<p class="muted">先让 A、B 同时读取同一个流程快照,再分别提交。每次提交本身都是原子事务,但 A、B 是两个不同事务。</p>
<div class="callout"><strong>看结果时记住:</strong>“一个请求要么全成功要么全失败”不等于“多个请求会自动合并成一个流程状态机”。</div>
</section>
<div class="grid">
<section>
<div class="panel">
<div class="panel-head">
<h2>1. 选择保存策略</h2>
<button id="resetBtn" class="ghost danger">重置演示</button>
</div>
<div class="strategy" id="strategyBar">
<button data-strategy="original" class="active">$original 保护共享计数</button>
<button data-strategy="plain">普通值覆盖</button>
<button data-strategy="expr">$expr 原子自增</button>
<button data-strategy="runtime">后端运行引擎</button>
</div>
<p id="strategyDesc" class="muted"></p>
</div>
<div class="panel">
<div class="panel-head">
<h2>2. 两个审批人读取快照</h2>
<div class="toolbar">
<button id="loadBothBtn">A、B 同时读取</button>
<button id="commitBothBtn" class="primary">按快照依次提交 A、B</button>
</div>
</div>
<div class="snapshot-list">
<div class="snapshot" id="snapshotA">
<div class="label"><span>审批人 A</span><span class="muted">未读取</span></div>
<code>点击“读取 A”</code>
<button data-load="A" style="margin-top:8px">读取 A</button>
</div>
<div class="snapshot" id="snapshotB">
<div class="label"><span>审批人 B</span><span class="muted">未读取</span></div>
<code>点击“读取 B”</code>
<button data-load="B" style="margin-top:8px">读取 B</button>
</div>
</div>
</div>
<div class="panel">
<div class="panel-head">
<h2>3. 提交审批</h2>
<div class="toolbar">
<button data-commit="A" class="primary">提交 A</button>
<button data-commit="B" class="primary">提交 B</button>
<button id="advanceBtn">单独尝试推进下一节点</button>
</div>
</div>
<p class="muted">必须先读取快照。若 A、B 都先读取,再点击“按快照依次提交 A、B”,可以复现并发结果。</p>
</div>
<div class="panel">
<div class="panel-head"><h2>请求预览</h2><span class="muted">当前选中策略生成的请求</span></div>
<pre id="requestPreview">先读取一个审批人的快照。</pre>
</div>
</section>
<aside>
<div class="panel">
<div class="panel-head"><h2>数据库状态</h2><span id="modeLabel" class="muted"></span></div>
<div class="tables">
<div>
<h3>wf_node_run</h3>
<table>
<tbody id="nodeRunBody"></tbody>
</table>
</div>
<div>
<h3>wf_task</h3>
<table>
<thead><tr><th>ID</th><th>审批人</th><th>状态</th></tr></thead>
<tbody id="taskBody"></tbody>
</table>
</div>
</div>
<div class="legend" style="margin-top:12px">
<span style="color:var(--amber)">等待中</span>
<span style="color:var(--green)">已完成</span>
<span style="color:var(--red)">失败/冲突</span>
</div>
</div>
<div class="panel">
<div class="panel-head"><h2>执行日志</h2><button id="clearLogBtn" class="ghost">清空</button></div>
<div id="log" class="log"></div>
</div>
</aside>
</div>
<p class="footer-note">提示:在“后端运行引擎”模式下,前端读取的 completed_count 只用于展示,真正提交时由后端锁定并重新计算。</p>
</main>
<script>
const descriptions = {
original: '$original 把 wf_node_run 的旧计数也作为条件。两个审批人基于同一个 0 读取时,第二个会冲突;这对并行会签是不正确的。',
plain: '普通值保存会把双方读到的 completed_count + 1 后写回 1。两个审批都成功,但共享计数丢了一次。',
expr: '$expr 在数据库中执行 completed_count = completed_count + 1,两个审批都能成功且计数正确;但通用保存不会自动推进下一节点。',
runtime: '后端运行引擎在一个受控事务内锁定 instance、node_run、task,重新计算并只推进一次;这是 workflow 的完整语义。',
}
const state = {
strategy: 'original',
nodeRun: { id: 'R001', status: 'running', required: 2, completed: 0 },
tasks: [
{ id: 'T001', assignee: 'A', status: 'pending' },
{ id: 'T002', assignee: 'B', status: 'pending' },
],
snapshots: { A: null, B: null },
nextTaskCreated: false,
logs: [],
}
const $ = (selector) => document.querySelector(selector)
const $$ = (selector) => [...document.querySelectorAll(selector)]
function clone(value) { return JSON.parse(JSON.stringify(value)) }
function reset() {
state.nodeRun = { id: 'R001', status: 'running', required: 2, completed: 0 }
state.tasks = [
{ id: 'T001', assignee: 'A', status: 'pending' },
{ id: 'T002', assignee: 'B', status: 'pending' },
]
state.snapshots = { A: null, B: null }
state.nextTaskCreated = false
state.logs = []
log('已重置:两个并行审批任务都处于 pending。', 'note')
render()
}
function log(message, type = '') {
const time = new Date().toLocaleTimeString()
state.logs.push({ message, type, time })
renderLog()
}
function renderLog() {
$('#log').innerHTML = state.logs.length
? state.logs.map(item => `<div class="log-entry ${item.type}"><span class="muted">${item.time}</span> ${escapeHtml(item.message)}</div>`).join('')
: '<div class="muted">暂无日志</div>'
$('#log').scrollTop = $('#log').scrollHeight
}
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]))
}
function loadSnapshot(assignee) {
const task = state.tasks.find(item => item.assignee === assignee)
state.snapshots[assignee] = {
taskStatus: task.status,
taskId: task.id,
completed: state.nodeRun.completed,
nodeStatus: state.nodeRun.status,
}
log(`${assignee} 读取快照:${task.id}=${task.status},completed_count=${state.nodeRun.completed}。`, 'note')
render()
}
function loadBoth() {
loadSnapshot('A')
loadSnapshot('B')
}
function previewRequest(assignee) {
const snapshot = state.snapshots[assignee]
if (!snapshot) {
$('#requestPreview').textContent = '先读取一个审批人的快照。'
return
}
const request = [
{
table: 'wf_task',
key_field: 'b_id',
updates: [{ b_id: snapshot.taskId, b_status: 'approved', $original: { b_status: 'pending' } }],
guards: { updates: [{ condition: "b_status = 'pending'", message: '任务已被处理' }] },
},
{
table: 'wf_node_run',
key_field: 'b_id',
updates: [{
b_id: 'R001',
b_completed_count: state.strategy === 'expr' || state.strategy === 'runtime'
? { $expr: 'b_completed_count + 1' }
: snapshot.completed + 1,
...(state.strategy === 'original' ? { $original: { b_completed_count: snapshot.completed } } : {}),
}],
},
]
$('#requestPreview').textContent = JSON.stringify(request, null, 2)
}
function commit(assignee) {
const snapshot = state.snapshots[assignee]
if (!snapshot) {
log(`${assignee} 尚未读取快照,无法提交。`, 'bad')
return
}
previewRequest(assignee)
const task = state.tasks.find(item => item.id === snapshot.taskId)
log(`${assignee} 开始提交 ${snapshot.taskId}:这是一个独立 saveobjt 请求。`)
if (state.strategy === 'runtime') {
commitRuntime(assignee, task)
return
}
if (task.status !== 'pending') {
log(`${assignee} 失败:任务当前是 ${task.status},整个请求回滚。`, 'bad')
return
}
if (state.strategy === 'original' && state.nodeRun.completed !== snapshot.completed) {
log(`${assignee} 失败:wf_node_run.completed_count 原值冲突(读取 ${snapshot.completed},当前 ${state.nodeRun.completed}),整个请求回滚。`, 'bad')
return
}
task.status = 'approved'
if (state.strategy === 'plain') {
state.nodeRun.completed = snapshot.completed + 1
log(`${assignee} 提交成功,但按旧快照写回 completed_count=${state.nodeRun.completed}。`, 'ok')
} else {
state.nodeRun.completed += 1
log(`${assignee} 提交成功,task 和 node_run 在同一个请求中提交。completed_count=${state.nodeRun.completed}。`, 'ok')
}
log('注意:通用 saveobjt 不会因为 completed_count 达标而自动执行“创建下一节点任务”的流程分支。', 'note')
render()
}
function commitRuntime(assignee, task) {
if (task.status !== 'pending') {
log(`${assignee} 失败:任务当前是 ${task.status},整个运行时动作回滚。`, 'bad')
return
}
task.status = 'approved'
state.nodeRun.completed += 1
log(`${assignee}:后端锁定并重新读取 node_run,提交成功,completed_count=${state.nodeRun.completed}。`, 'ok')
if (state.nodeRun.completed >= state.nodeRun.required && state.nodeRun.status === 'running') {
state.nodeRun.status = 'completed'
state.nextTaskCreated = true
log('后端发现已达到 required_count,只推进一次节点并创建下一节点任务。', 'ok')
}
render()
}
function advance() {
if (state.nodeRun.status === 'completed') {
log('推进失败:节点已经完成。实际系统通常会按 request_id / 状态返回幂等结果,而不是再次创建任务。', 'bad')
return
}
if (state.nodeRun.completed < state.nodeRun.required) {
log(`推进失败:completed_count=${state.nodeRun.completed},required_count=${state.nodeRun.required},guard 不成立,当前请求会回滚。`, 'bad')
return
}
state.nodeRun.status = 'completed'
state.nextTaskCreated = true
log('单独推进成功,但这是审批之后的第二个请求;需要额外的状态 guard 和幂等处理。', 'ok')
render()
}
function render() {
$('#strategyDesc').textContent = descriptions[state.strategy]
$('#modeLabel').textContent = `策略:${state.strategy}`
$('#nodeRunBody').innerHTML = `
<tr><th>ID</th><td>${state.nodeRun.id}</td></tr>
<tr><th>状态</th><td><span class="status ${state.nodeRun.status}">${state.nodeRun.status}</span></td></tr>
<tr><th>已通过</th><td>${state.nodeRun.completed}</td></tr>
<tr><th>需要通过</th><td>${state.nodeRun.required}</td></tr>
<tr><th>下一节点任务</th><td>${state.nextTaskCreated ? '<span class="status completed">已创建</span>' : '未创建'}</td></tr>`
$('#taskBody').innerHTML = state.tasks.map(task => `
<tr><td>${task.id}</td><td>${task.assignee}</td><td><span class="status ${task.status}">${task.status}</span></td></tr>`).join('')
for (const assignee of ['A', 'B']) {
const snapshot = state.snapshots[assignee]
const box = $(`#snapshot${assignee}`)
box.classList.toggle('loaded', Boolean(snapshot))
box.querySelector('.label').innerHTML = `<span>审批人 ${assignee}</span><span class="muted">${snapshot ? '已读取' : '未读取'}</span>`
box.querySelector('code').textContent = snapshot
? `task=${snapshot.taskId} ${snapshot.taskStatus}\ncompleted_count=${snapshot.completed}`
: '点击“读取 ' + assignee + '”'
}
renderLog()
}
$('#strategyBar').addEventListener('click', event => {
const button = event.target.closest('button[data-strategy]')
if (!button) return
state.strategy = button.dataset.strategy
$$('#strategyBar button').forEach(item => item.classList.toggle('active', item === button))
log(`切换策略:${button.textContent}。建议重置后重新演示。`, 'note')
render()
})
$$('[data-load]').forEach(button => button.addEventListener('click', () => loadSnapshot(button.dataset.load)))
$$('[data-commit]').forEach(button => button.addEventListener('click', () => commit(button.dataset.commit)))
$('#loadBothBtn').addEventListener('click', loadBoth)
$('#commitBothBtn').addEventListener('click', () => { commit('A'); commit('B') })
$('#advanceBtn').addEventListener('click', advance)
$('#resetBtn').addEventListener('click', reset)
$('#clearLogBtn').addEventListener('click', () => { state.logs = []; renderLog() })
log('已加载演示数据:A、B 各有一个待办,会签要求 2 人通过。', 'note')
render()
</script>
</body>
</html>