Files
workspace/code/fms/workflow-saveobjt-demo.html
T
2026-09-28 17:13:37 +08:00

418 lines
18 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>