20260928171336
This commit is contained in:
1 parent
f22e67fc85
commit
cbd49ded6f
34 files changed
+738
-7842
No files matched your search
@@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|
||||
Reference in new issue
Block a user