Compare commits

..

10 Commits

Author SHA1 Message Date
selfrelease 34a279721e feat: add organization approval rules 2026-07-19 07:49:37 +08:00
selfrelease cd72ec3756 test: verify generated workflow execution 2026-07-18 23:06:23 +08:00
selfrelease 9c366cc872 feat: add visual process designer 2026-07-18 22:56:29 +08:00
selfrelease 89c44a61a7 feat: add process binding designer 2026-07-18 22:45:06 +08:00
selfrelease 342a1fcb33 feat: add web form designer 2026-07-18 22:25:23 +08:00
selfrelease 15abd8a32f feat: add versioned form and process routing 2026-07-18 21:26:44 +08:00
selfrelease 24101398d8 test: add complete iOS MVP business suite 2026-07-18 20:54:23 +08:00
selfrelease e19bdb8728 fix: harden iOS authentication recovery 2026-07-18 20:04:00 +08:00
selfrelease b311be6aa6 feat: complete MVP administration tools 2026-07-18 19:47:20 +08:00
selfrelease d0a2f4f923 feat: add redacted tenant audit queries 2026-07-18 19:27:29 +08:00
71 changed files with 5248 additions and 71 deletions
+5
View File
@@ -34,3 +34,8 @@ firebase-service-account*.json
.local/
coverage/
reports/
node_modules/
admin-web/dist/
*.tsbuildinfo
admin-web/vite.config.js
admin-web/vite.config.d.ts
+20
View File
@@ -92,6 +92,26 @@ flutter-verify:
paths:
- mobile/reports/flutter-test.json
admin-web-verify:
stage: verify
image: node:24-alpine
cache:
key:
files:
- admin-web/package-lock.json
paths:
- .cache/npm/
before_script:
- cd admin-web
- npm ci --cache ../.cache/npm
script:
- npm test
- npm run build
artifacts:
expire_in: 7 days
paths:
- admin-web/dist/
android-debug-build:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
+4
View File
@@ -19,6 +19,7 @@ AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实
```text
backend/ Kotlin + Spring Boot 模块化单体
mobile/ Flutter 移动客户端
admin-web/ React + TypeScript 表单设计与流程配置管理端
ai-service/ Python AI 服务
contracts/ OpenAPI 与事件契约
deploy/ 本地与部署配置
@@ -71,6 +72,8 @@ AIOA_FULL_BUILD=1 ./scripts/verify-all.sh
Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engineering/mobile-schema-forms.md](docs/engineering/mobile-schema-forms.md)。
Web 管理端已实现可视化表单设计、实时移动卡片预览和版本发布。运行与验证方式见 [admin-web/README.md](admin-web/README.md)。
## 持续集成
仓库根目录的 `.gitlab-ci.yml` 默认执行:
@@ -78,6 +81,7 @@ Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engine
- JDK 21 后端测试,并上传 JUnit 报告;
- Python 3.12 AI 服务编译检查与测试;
- Flutter 格式检查、静态分析和测试;
- React 管理端单元测试和生产构建;
- Android Debug APK 构建与产物归档。
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
+46
View File
@@ -0,0 +1,46 @@
# AIOA 管理设计中心
React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
## 本地运行
先从仓库根目录启动 PostgreSQL、Keycloak 和后端:
```bash
docker compose --env-file .env.example -f deploy/compose/compose.yaml up -d postgres keycloak
cd backend
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
export GRADLE_USER_HOME=/tmp/aioa-gradle-home
./gradlew --no-daemon :boot:bootRun
```
再启动管理端:
```bash
cd admin-web
npm ci
npm run dev -- --host 127.0.0.1
```
访问 `http://127.0.0.1:5173`,通过 Keycloak 登录。默认 OIDC Issuer 为 `http://localhost:8081/realms/aioa`,后端 API 为 `http://localhost:8080/api/v1`;需要覆盖时可设置:
```bash
VITE_OIDC_ISSUER=http://localhost:8081/realms/aioa
VITE_API_BASE_URL=http://localhost:8080/api/v1
```
## 验证
```bash
npm test
npm run build
```
Keycloak Realm 首次导入时会创建 `aioa-admin-web` 公共客户端并启用 Authorization Code + PKCE S256。若本机 Keycloak 已在加入该客户端之前启动,需要在开发环境重建 Keycloak 容器或通过管理控制台补入同名客户端。
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+2959
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "aioa-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"oidc-client-ts": "^3.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"jsdom": "^26.1.0",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vitest": "^3.2.4"
}
}
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
import { ProcessBindings } from './ProcessBindings';
import { ProcessDesigner } from './ProcessDesigner';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
{ control: 'text', label: '单行文本' }, { control: 'textArea', label: '多行文本' },
{ control: 'select', label: '下拉选择' }, { control: 'dateTime', label: '日期时间' },
];
export function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (location.pathname === '/callback') { await userManager.signinRedirectCallback(); history.replaceState({}, '', '/'); }
const user = await userManager.getUser(); setAuthenticated(Boolean(user && !user.expired)); setLoading(false);
})().catch(() => setLoading(false));
}, []);
if (loading) return <div className="center"></div>;
if (!authenticated) return <Login />;
return <Workspace onLogout={() => userManager.signoutRedirect()} />;
}
function Login() {
return <main className="login"><div className="brandMark">A</div><h1>AIOA </h1><p> Flowable </p><button onClick={() => userManager.signinRedirect()}>使 Keycloak </button></main>;
}
function Workspace({ onLogout }: { onLogout: () => void }) {
const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
return <div className="workspace"><header><div><strong>AIOA</strong><span></span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}></button><button className={view === 'processes' ? 'navActive' : 'navButton'} onClick={() => setView('processes')}></button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}></button></nav><button className="ghost" onClick={onLogout}>退</button></header>{view === 'forms' ? <Designer /> : view === 'processes' ? <ProcessDesigner /> : <ProcessBindings />}</div>;
}
function Designer() {
const [formKey, setFormKey] = useState('leave-request');
const [title, setTitle] = useState('请假申请');
const [fields, setFields] = useState<Field[]>([
{ id: crypto.randomUUID(), key: 'type', label: '请假类型', control: 'select', required: true, options: ['PERSONAL', 'SICK', 'ANNUAL'] },
{ id: crypto.randomUUID(), key: 'startsAt', label: '开始时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'endsAt', label: '结束时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'reason', label: '请假原因', control: 'textArea', required: true },
]);
const [selected, setSelected] = useState(fields[0].id);
const [versions, setVersions] = useState<FormVersion[]>([]);
const [message, setMessage] = useState('');
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
const selectedField = fields.find(f => f.id === selected);
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
useEffect(() => { void loadVersions(); }, []);
function add(control: ControlType) {
const index = fields.length + 1;
const field: Field = { id: crypto.randomUUID(), key: `field${index}`, label: `字段 ${index}`, control, required: false, ...(control === 'select' ? { options: ['OPTION_1', 'OPTION_2'] } : {}) };
setFields([...fields, field]); setSelected(field.id);
}
function update(patch: Partial<Field>) { setFields(fields.map(f => f.id === selected ? { ...f, ...patch } : f)); }
function move(id: string, delta: number) { const from = fields.findIndex(f => f.id === id), to = from + delta; if (to < 0 || to >= fields.length) return; const next = [...fields]; [next[from], next[to]] = [next[to], next[from]]; setFields(next); }
function dropField(event: React.DragEvent, targetId: string) { event.preventDefault(); const sourceId = event.dataTransfer.getData('field'); const source = fields.findIndex(f => f.id === sourceId), target = fields.findIndex(f => f.id === targetId); if (source < 0 || target < 0) return; const next = [...fields]; const [item] = next.splice(source, 1); next.splice(target, 0, item); setFields(next); }
async function save() {
try { const result = await api<{ version: number }>('/admin/process-configuration/forms', { method: 'POST', body: JSON.stringify({ formKey, ...schemas }) }); setMessage(`草稿 v${result.version} 已保存`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '保存失败'); }
}
async function publish(version: number) {
try { await api(`/admin/process-configuration/forms/${formKey}/${version}/publish`, { method: 'POST' }); setMessage(`v${version} 已发布`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '发布失败'); }
}
return <div className="appShell">
<div className="subHeader"><div><strong></strong><span>Schema </span></div><button onClick={save}>稿</button></div>
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<section className="meta"><label> Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label><input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
<main className="designer">
<aside><h2></h2>{palette.map(p => <button className="palette" key={p.control} onClick={() => add(p.control)}> {p.label}</button>)}<h2></h2>{versions.filter(v => v.form_key === formKey).map(v => <div className="version" key={v.version}><span>v{v.version} · {v.status}</span>{v.status === 'DRAFT' && <button onClick={() => publish(v.version)}></button>}</div>)}</aside>
<section className="canvas"><div className="phone"><div className="phoneHeader">{title}</div>{fields.map((f, i) => <div className={`field ${selected === f.id ? 'selected' : ''}`} key={f.id} draggable onDragStart={e => e.dataTransfer.setData('field', f.id)} onDragOver={e => e.preventDefault()} onDrop={e => dropField(e, f.id)} onClick={() => setSelected(f.id)}><label>{f.label}{f.required && ' *'}</label>{f.control === 'textArea' ? <textarea disabled placeholder={f.placeholder} /> : f.control === 'select' ? <select disabled><option>{f.options?.[0] ?? '请选择'}</option></select> : <input disabled placeholder={f.control === 'dateTime' ? '请选择日期和时间' : f.placeholder} />}<div className="fieldActions"><button onClick={e => { e.stopPropagation(); move(f.id, -1); }}></button><button onClick={e => { e.stopPropagation(); move(f.id, 1); }}></button><button onClick={e => { e.stopPropagation(); setFields(fields.filter(x => x.id !== f.id)); }}>×</button></div><small>{i + 1}</small></div>)}</div></section>
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}<details><summary> Schema</summary><pre>{JSON.stringify(schemas, null, 2)}</pre></details></aside>
</main>
</div>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
type FormVersion = { form_key: string; version: number; status: string };
type ProcessDefinition = { id: string; key: string; name?: string; version: number; suspended: boolean };
type Binding = {
id: string; business_type: string; form_key: string; form_version: number;
process_definition_key: string; leave_type?: string; min_duration_minutes?: number;
max_duration_minutes?: number; priority: number; status: 'ACTIVE' | 'INACTIVE';
};
const leaveTypes = [{ value: '', label: '全部请假类型' }, { value: 'PERSONAL', label: '事假' }, { value: 'SICK', label: '病假' }, { value: 'ANNUAL', label: '年假' }];
const minutesToDays = (value?: number) => value == null ? '不限' : `${Number((value / 480).toFixed(1))}`;
export function ProcessBindings() {
const [forms, setForms] = useState<FormVersion[]>([]);
const [definitions, setDefinitions] = useState<ProcessDefinition[]>([]);
const [bindings, setBindings] = useState<Binding[]>([]);
const [message, setMessage] = useState('');
const [formKey, setFormKey] = useState('leave-request');
const published = forms.filter(form => form.status === 'PUBLISHED');
const selectedVersions = published.filter(form => form.form_key === formKey);
const [formVersion, setFormVersion] = useState(1);
const [processKey, setProcessKey] = useState('leaveApproval');
const [leaveType, setLeaveType] = useState('');
const [minDays, setMinDays] = useState('');
const [maxDays, setMaxDays] = useState('');
const [priority, setPriority] = useState(100);
async function load() {
try {
const [formResult, definitionResult, bindingResult] = await Promise.all([
api<FormVersion[]>('/admin/process-configuration/forms'),
api<ProcessDefinition[]>('/admin/workflows/definitions'),
api<Binding[]>('/admin/process-configuration/bindings'),
]);
setForms(formResult); setDefinitions(definitionResult); setBindings(bindingResult);
const firstPublished = formResult.find(form => form.status === 'PUBLISHED');
if (firstPublished) { setFormKey(firstPublished.form_key); setFormVersion(firstPublished.version); }
const firstDefinition = definitionResult.find(definition => !definition.suspended);
if (firstDefinition) setProcessKey(firstDefinition.key);
} catch (error) { setMessage(error instanceof Error ? error.message : '配置加载失败'); }
}
useEffect(() => { void load(); }, []);
useEffect(() => { if (selectedVersions.length && !selectedVersions.some(form => form.version === formVersion)) setFormVersion(selectedVersions[0].version); }, [formKey, forms]);
const ruleSummary = useMemo(() => {
const type = leaveTypes.find(item => item.value === leaveType)?.label ?? leaveType;
return `${type} · ${minDays || '0'}${maxDays || '∞'} 天 · 优先级 ${priority}`;
}, [leaveType, minDays, maxDays, priority]);
async function createBinding() {
try {
await api('/admin/process-configuration/bindings', { method: 'POST', body: JSON.stringify({
businessType: 'LEAVE_REQUEST', formKey, formVersion, processDefinitionKey: processKey,
leaveType: leaveType || null,
minDurationMinutes: minDays === '' ? null : Math.round(Number(minDays) * 480),
maxDurationMinutes: maxDays === '' ? null : Math.round(Number(maxDays) * 480), priority,
}) });
setMessage('流程绑定已启用'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : '绑定创建失败'); }
}
async function toggle(binding: Binding) {
try {
const status = binding.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
await api(`/admin/process-configuration/bindings/${binding.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
setMessage(status === 'ACTIVE' ? '绑定已启用' : '绑定已停用'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : '状态修改失败'); }
}
return <section className="bindingPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="bindingIntro"><div><h1></h1><p> Flowable </p></div><span className="safeBadge"></span></div>
<div className="bindingGrid">
<div className="configCard"><h2></h2>
<label><input value="请假申请" disabled /></label>
<div className="twoColumns"><label><select value={formKey} onChange={e => setFormKey(e.target.value)}>{[...new Set(published.map(form => form.form_key))].map(key => <option key={key}>{key}</option>)}</select></label><label><select value={formVersion} onChange={e => setFormVersion(Number(e.target.value))}>{selectedVersions.map(form => <option key={form.version} value={form.version}>v{form.version} · </option>)}</select></label></div>
<label>Flowable <select value={processKey} onChange={e => setProcessKey(e.target.value)}>{definitions.filter(definition => !definition.suspended).map(definition => <option key={definition.id} value={definition.key}>{definition.name || definition.key} · v{definition.version}</option>)}</select></label>
<label><select value={leaveType} onChange={e => setLeaveType(e.target.value)}>{leaveTypes.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
<div className="twoColumns"><label><input type="number" min="0" step="0.5" value={minDays} onChange={e => setMinDays(e.target.value)} placeholder="不限" /></label><label><input type="number" min="0" step="0.5" value={maxDays} onChange={e => setMaxDays(e.target.value)} placeholder="不限" /></label></div>
<label><input type="number" value={priority} onChange={e => setPriority(Number(e.target.value))} /></label>
<div className="rulePreview"><strong></strong><span>{ruleSummary}</span></div>
<button className="primaryWide" onClick={createBinding} disabled={!formKey || !processKey}></button>
</div>
<div className="flowCard"><h2></h2><div className="flowPreview"><div className="flowNode source"><span>LEAVE_REQUEST</span></div><i></i><div className="flowNode decision"><span> · · </span></div><i></i><div className="flowNode target"><span>{processKey || '请选择流程'}</span></div></div><div className="guardrails"><p> </p><p> </p><p> </p><p> </p></div></div>
</div>
<div className="bindingList"><h2></h2>{bindings.length === 0 ? <p className="empty"></p> : bindings.map(binding => <article key={binding.id} className={binding.status === 'ACTIVE' ? '' : 'inactive'}><div><strong>{binding.form_key} · v{binding.form_version}</strong><span>{binding.business_type} {binding.process_definition_key}</span></div><div className="conditions"><span>{binding.leave_type || '全部类型'}</span><span>{minutesToDays(binding.min_duration_minutes)} {minutesToDays(binding.max_duration_minutes)}</span><span>P{binding.priority}</span></div><button className={binding.status === 'ACTIVE' ? 'dangerGhost' : ''} onClick={() => toggle(binding)}>{binding.status === 'ACTIVE' ? '停用' : '启用'}</button></article>)}</div>
</section>;
}
+80
View File
@@ -0,0 +1,80 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
type Step = { id: string; name: string; assigneeVariable: Assignee };
type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string };
const fallbackRules: ApprovalRule[] = [
{ variable: 'approverId', label: '发起人所在部门主管', sourceType: 'POSITION', selector: 'manager', scope: 'APPLICANT_DEPARTMENT', emptyPolicy: 'REJECT_SUBMISSION', description: '根据发起人的主任职解析部门主管。' },
{ variable: 'oaAdministratorId', label: '租户 OA 管理员', sourceType: 'ROLE', selector: 'oa_admin', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 OA 管理员。' },
{ variable: 'hrReviewerId', label: '租户 HR 复核人', sourceType: 'ROLE', selector: 'hr_reviewer', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 HR 复核人。' },
];
const modes: { value: Mode; label: string; hint: string }[] = [
{ value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' },
{ value: 'PARALLEL', label: '并行会签', hint: '同时审批,全部通过才结束' },
{ value: 'CONDITIONAL', label: '条件审批', hint: '达到时长阈值后增加复核' },
];
export function ProcessDesigner() {
const [key, setKey] = useState('leaveApprovalCustom');
const [name, setName] = useState('自定义请假审批');
const [mode, setMode] = useState<Mode>('SERIAL');
const [thresholdDays, setThresholdDays] = useState(3);
const [steps, setSteps] = useState<Step[]>([
{ id: crypto.randomUUID(), name: '部门主管审批', assigneeVariable: 'approverId' },
{ id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' },
]);
const [selected, setSelected] = useState(steps[0].id);
const [message, setMessage] = useState('');
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
useEffect(() => { api<ApprovalRule[]>('/admin/process-configuration/approver-rules').then(setRules).catch(error => setMessage(error instanceof Error ? error.message : '审批规则加载失败')); }, []);
const selectedStep = steps.find(step => step.id === selected);
const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps;
const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]);
const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
function changeMode(next: Mode) {
setMode(next);
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
}
function update(patch: Partial<Step>) { setSteps(steps.map(step => step.id === selected ? { ...step, ...patch } : step)); }
function addStep() {
const step: Step = { id: crypto.randomUUID(), name: `审批节点 ${steps.length + 1}`, assigneeVariable: 'approverId' };
setSteps([...steps, step]); setSelected(step.id);
}
function move(id: string, delta: number) {
const from = steps.findIndex(step => step.id === id), to = from + delta;
if (to < 0 || to >= steps.length) return;
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
}
async function deploy() {
try {
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
key, name, mode, steps: effectiveSteps.map(({ name: stepName, assigneeVariable }) => ({ name: stepName, assigneeVariable })),
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
}) });
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
}
return <section className="processDesignerPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div>
<section className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></section>
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
<div className="processWorkspace">
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{rules.find(item => item.variable === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}` : undefined} />}</div>)}</div>
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
</div>
<aside className="processProperties"><h2></h2>{mode === 'CONDITIONAL' && <label><input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2></h2>{selectedStep ? <><label><input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label><select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span></span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span></span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small></small></div>}<div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}></button><button onClick={() => move(selectedStep.id, 1)}></button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}></button></div></> : <p></p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}> </button>}{duplicateRestrictedRule && <div className="validationError"></div>}<div className="securityNote"><strong></strong><p></p></div></aside>
</div>
</section>;
}
function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return <div className={`templateNode ${kind}`}><strong>{title}</strong><small>{subtitle}</small></div>; }
function Arrow({ label }: { label?: string }) { return <div className="graphArrow">{label && <span>{label}</span>}</div>; }
+30
View File
@@ -0,0 +1,30 @@
import { accessToken } from './auth';
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api/v1';
const deviceId = localStorage.getItem('aioa.admin.device') ?? crypto.randomUUID();
localStorage.setItem('aioa.admin.device', deviceId);
let registeredToken = '';
async function ensureDevice(token: string) {
if (registeredToken === token) return;
const response = await fetch(`${baseUrl}/devices/register`, {
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deviceId, name: navigator.userAgent.slice(0, 200), platform: 'OTHER', appVersion: 'admin-web' }),
});
if (!response.ok) throw new Error(`设备注册失败 (${response.status})`);
registeredToken = token;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await accessToken();
if (!token) throw new Error('登录已失效');
await ensureDevice(token);
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, 'X-AIOA-Device-Id': deviceId, 'Content-Type': 'application/json', ...init.headers },
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.detail ?? `请求失败 (${response.status})`);
if (response.status === 204 || response.headers.get('content-length') === '0') return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
+17
View File
@@ -0,0 +1,17 @@
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const issuer = import.meta.env.VITE_OIDC_ISSUER ?? 'http://localhost:8081/realms/aioa';
export const userManager = new UserManager({
authority: issuer,
client_id: 'aioa-admin-web',
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
export async function accessToken(): Promise<string | null> {
const user = await userManager.getUser();
return user && !user.expired ? user.access_token : null;
}
+5
View File
@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildSchemas } from './schema';
describe('buildSchemas', () => {
it('builds required fields and safe controls', () => {
const result = buildSchemas('请假申请', [{ id: '1', key: 'reason', label: '原因', control: 'textArea', required: true }]);
expect(result.dataSchema.required).toEqual(['reason']);
expect(result.uiSchema.sections[0].controls[0].control).toBe('textArea');
});
});
+17
View File
@@ -0,0 +1,17 @@
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[] };
export function buildSchemas(title: string, fields: Field[]) {
const properties: Record<string, unknown> = {};
for (const field of fields) {
properties[field.key] = field.control === 'select'
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
: field.control === 'dateTime'
? { type: 'string', format: 'date-time' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
}
return {
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
uiSchema: { description: `${title} · 由 AIOA 表单设计器生成`, sections: [{ title: '基本信息', controls: fields.map(f => ({ field: f.key, label: f.label, control: f.control, placeholder: f.placeholder, ...(f.control === 'select' ? { optionLabels: Object.fromEntries((f.options ?? []).map(v => [v, v])) } : {}) })) }] },
};
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
"strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
"resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx"
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
"include": ["vite.config.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom' }, server: { port: 5173 } });
+1
View File
@@ -35,6 +35,7 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -0,0 +1,21 @@
package com.all8ai.aioa.admin.configuration
data class ApprovalRuleOption(
val variable:String,val label:String,val sourceType:String,val selector:String,
val scope:String,val emptyPolicy:String,val description:String,
)
fun approvalRuleCatalog()=listOf(
ApprovalRuleOption(
"approverId","发起人所在部门主管","POSITION","manager","APPLICANT_DEPARTMENT","REJECT_SUBMISSION",
"根据发起人的有效主任职定位部门,再选择该部门有效的 manager 岗位人员。",
),
ApprovalRuleOption(
"oaAdministratorId","租户 OA 管理员","ROLE","oa_admin","TENANT","REJECT_SUBMISSION",
"在当前租户内选择拥有有效 oa_admin 角色且账号状态正常的人员。",
),
ApprovalRuleOption(
"hrReviewerId","租户 HR 复核人","ROLE","hr_reviewer","TENANT","REJECT_SUBMISSION",
"在当前租户内选择拥有有效 hr_reviewer 角色且账号状态正常的人员。",
),
)
@@ -0,0 +1,62 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.flowable.engine.RepositoryService
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/process-configuration")
class ProcessConfigurationController(
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
private val flowable:RepositoryService,private val audit:AuditService,
) {
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/approver-rules") fun approverRules(@AuthenticationPrincipal jwt:Jwt):List<ApprovalRuleOption> { actor(jwt);return approvalRuleCatalog() }
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
}
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
}
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
val a=actor(jwt)
if(!isValidDurationRange(body.minDurationMinutes,body.maxDurationMinutes)) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_BINDING_RANGE_INVALID","时长范围无效")
val publishedFormExists=dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM form.definition WHERE tenant_id=? AND form_key=? AND version=? AND status='PUBLISHED') found",a.tenantId,body.formKey,body.formVersion)?.get("found",Boolean::class.java)==true
if(!publishedFormExists) throw ApiException(HttpStatus.BAD_REQUEST,"PUBLISHED_FORM_NOT_FOUND","已发布表单版本不存在")
if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
}
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
@PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
val a=actor(jwt);val xml=generateProcessTemplate(body)
val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
}
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
}
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
data class BindingStatusCommand(val status:String)
internal fun isValidDurationRange(min:Long?,max:Long?):Boolean = min?.let { it>=0 } != false && max?.let { it>=0 } != false && (min==null || max==null || min<=max)
@@ -0,0 +1,83 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
data class ProcessTemplateCommand(
val key:String,val name:String,val mode:String,val steps:List<ApprovalStepCommand>,
val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
)
private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
private val allowedConditions=setOf("durationMinutes")
fun generateProcessTemplate(command:ProcessTemplateCommand):String {
if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
if(command.mode in setOf("PARALLEL","CONDITIONAL") && command.steps.map{it.assigneeVariable}.distinct().size!=command.steps.size) invalid("并行或条件复核节点必须使用不同的审批人规则")
if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
val body=when(command.mode){
"SERIAL"->serial(command.steps)
"PARALLEL"->parallel(command.steps)
else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
}
return """<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/designer">
<process id="${command.key}" name="${xml(command.name)}" isExecutable="true">
$body
</process>
</definitions>"""
}
private fun serial(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
steps.forEachIndexed { index,step ->
appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
}
appendEnds()
}.trimEnd()
private fun parallel(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-split\" sourceRef=\"start\" targetRef=\"parallelSplit\"/>")
appendLine(" <parallelGateway id=\"parallelSplit\" name=\"并行会签\"/>")
steps.forEachIndexed { index,step ->
appendLine(" <sequenceFlow id=\"flow-split-task$index\" sourceRef=\"parallelSplit\" targetRef=\"task$index\"/>")
appendDecisionTask(index,step,"parallelJoin")
}
appendLine(" <parallelGateway id=\"parallelJoin\" name=\"全部通过\"/>")
appendLine(" <sequenceFlow id=\"flow-join-end\" sourceRef=\"parallelJoin\" targetRef=\"approvedEnd\"/>")
appendEnds()
}.trimEnd()
private fun conditional(steps:List<ApprovalStepCommand>,variable:String,threshold:Long):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
appendDecisionTask(0,steps[0],"routeDecision")
appendLine(" <exclusiveGateway id=\"routeDecision\" name=\"条件路由\"/>")
appendLine(" <sequenceFlow id=\"flow-condition-short\" sourceRef=\"routeDecision\" targetRef=\"approvedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable <= $threshold}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-condition-long\" sourceRef=\"routeDecision\" targetRef=\"task1\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable > $threshold}]]></conditionExpression></sequenceFlow>")
appendDecisionTask(1,steps[1],"approvedEnd")
appendEnds()
}.trimEnd()
private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
appendLine(" <userTask id=\"task$index\" name=\"${xml(step.name)}\" flowable:assignee=\"\${${step.assigneeVariable}}\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-decision\" sourceRef=\"task$index\" targetRef=\"decision$index\"/>")
appendLine(" <exclusiveGateway id=\"decision$index\" name=\"审批结果\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-approved\" sourceRef=\"decision$index\" targetRef=\"$approvedTarget\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == true}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-task$index-rejected\" sourceRef=\"decision$index\" targetRef=\"rejectedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == false}]]></conditionExpression></sequenceFlow>")
}
private fun StringBuilder.appendEnds(){
appendLine(" <endEvent id=\"approvedEnd\" name=\"已批准\"/>")
appendLine(" <endEvent id=\"rejectedEnd\" name=\"已驳回\"><terminateEventDefinition/></endEvent>")
}
private fun xml(value:String)=value.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace("\"","&quot;")
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
@@ -0,0 +1,35 @@
package com.all8ai.aioa.admin.metrics
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import org.jooq.DSLContext
import org.flowable.engine.RuntimeService
import org.flowable.engine.TaskService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
data class OperationsMetrics(
val leaveByStatus:Map<String,Int>,val activeWorkflowInstances:Long,val activeWorkflowTasks:Long,
val unreadNotifications:Int,val pendingPush:Int,val failedPushAttempts:Int,val activeDevices:Int,
)
@RestController @RequestMapping("/api/v1/admin/metrics")
class OperationsMetricsController(private val users:CurrentUserService,private val dsl:DSLContext,private val runtime:RuntimeService,private val tasks:TaskService) {
@GetMapping fun metrics(@AuthenticationPrincipal jwt:Jwt):OperationsMetrics {
val actor=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")); actor.requirePermission(ToolPermission.OPERATIONS_METRICS_READ_TENANT)
fun count(sql:String,vararg bindings:Any):Int=dsl.fetchOne(sql,*bindings)?.get("count",Int::class.java)?:0
val statuses=dsl.fetch("SELECT status,COUNT(*)::int count FROM business.leave_request WHERE tenant_id=? GROUP BY status",actor.tenantId)
.associate { it.get("status",String::class.java)!! to it.get("count",Int::class.java)!! }
return OperationsMetrics(statuses,
runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).count(),
tasks.createTaskQuery().processVariableValueEquals("tenantId",actor.tenantId.toString()).active().count(),
count("SELECT COUNT(*)::int count FROM communication.notification WHERE tenant_id=? AND read_at IS NULL",actor.tenantId),
count("SELECT COUNT(*)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=? AND o.status='PENDING'",actor.tenantId),
count("SELECT COALESCE(SUM(o.attempts),0)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=?",actor.tenantId),
count("SELECT COUNT(*)::int count FROM identity.user_device WHERE tenant_id=? AND status='ACTIVE'",actor.tenantId))
}
}
@@ -0,0 +1,77 @@
package com.all8ai.aioa.admin.organization
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
data class AdminDepartment(val id: UUID, val code: String, val name: String, val status: String)
data class AdminRole(val id: UUID, val code: String, val name: String, val status: String)
data class AdminUser(val id: UUID, val username: String, val displayName: String, val email: String?, val status: String, val departmentId: UUID?, val positionId: UUID?, val roles: Set<String>)
@Service
class AdminOrganizationService(private val dsl: DSLContext, private val audit: AuditService) {
fun departments(actor: CurrentUser): List<AdminDepartment> {
require(actor)
return dsl.fetch("SELECT id, code, name, status FROM organization.department WHERE tenant_id = ? ORDER BY code", actor.tenantId)
.map { AdminDepartment(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
}
fun roles(actor: CurrentUser): List<AdminRole> {
require(actor)
return dsl.fetch("SELECT id, code, name, status FROM authz.role WHERE tenant_id = ? ORDER BY code", actor.tenantId)
.map { AdminRole(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
}
fun users(actor: CurrentUser): List<AdminUser> {
require(actor)
return dsl.fetch("""
SELECT u.id, u.username, u.display_name, u.email, u.status, a.department_id, a.position_id,
COALESCE(array_agg(r.code ORDER BY r.code) FILTER (WHERE r.code IS NOT NULL), '{}') AS roles
FROM identity.user_account u
LEFT JOIN organization.user_assignment a ON a.tenant_id=u.tenant_id AND a.user_id=u.id AND a.is_primary=TRUE AND a.effective_until IS NULL
LEFT JOIN authz.user_role ur ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id AND ur.effective_until IS NULL
LEFT JOIN authz.role r ON r.tenant_id=ur.tenant_id AND r.id=ur.role_id
WHERE u.tenant_id=? GROUP BY u.id,a.department_id,a.position_id ORDER BY u.username
""".trimIndent(), actor.tenantId).map {
AdminUser(it.get("id", UUID::class.java)!!, it.get("username", String::class.java)!!, it.get("display_name", String::class.java)!!,
it.get("email", String::class.java), it.get("status", String::class.java)!!, it.get("department_id", UUID::class.java),
it.get("position_id", UUID::class.java), (it.get("roles") as? Array<*>)?.filterIsInstance<String>()?.toSet().orEmpty())
}
}
@Transactional
fun createDepartment(actor: CurrentUser, code: String, name: String): AdminDepartment {
require(actor); val c = valid(code, 64); val n = valid(name, 200); val id = UuidV7.generate()
try { dsl.execute("INSERT INTO organization.department(id,tenant_id,code,name,status) VALUES(?,?,?,?,'ACTIVE')", id, actor.tenantId, c, n) }
catch (_: Exception) { throw ApiException(HttpStatus.CONFLICT, "DEPARTMENT_CODE_EXISTS", "部门编码已存在") }
audit.recordSuccess(actor, "DEPARTMENT_CREATED", "DEPARTMENT", id.toString(), null, mapOf("code" to c))
return AdminDepartment(id, c, n, "ACTIVE")
}
@Transactional
fun replaceRoles(actor: CurrentUser, userId: UUID, roleCodes: Set<String>) {
require(actor); if (roleCodes.isEmpty()) throw ApiException(HttpStatus.BAD_REQUEST, "ROLES_REQUIRED", "至少保留一个角色")
val bindings = mutableListOf<Any>(actor.tenantId).apply { addAll(roleCodes) }.toTypedArray()
val roleIds = dsl.fetch("SELECT id,code FROM authz.role WHERE tenant_id=? AND code IN (${roleCodes.joinToString { "?" }}) AND status='ACTIVE'", *bindings)
if (roleIds.size != roleCodes.size) throw ApiException(HttpStatus.BAD_REQUEST, "ROLE_INVALID", "包含不存在的角色")
if (dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM identity.user_account WHERE tenant_id=? AND id=?) AS ok", actor.tenantId, userId)?.get("ok", Boolean::class.java) != true) throw ApiException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "用户不存在")
dsl.execute("DELETE FROM authz.user_role WHERE tenant_id=? AND user_id=?", actor.tenantId, userId)
roleIds.forEach { dsl.execute("INSERT INTO authz.user_role(tenant_id,user_id,role_id) VALUES(?,?,?)", actor.tenantId, userId, it.get("id", UUID::class.java)) }
audit.recordSuccess(actor, "USER_ROLES_REPLACED", "USER", userId.toString(), null, mapOf("roles" to roleCodes.sorted()))
}
@Transactional
fun assign(actor: CurrentUser, userId: UUID, departmentId: UUID, positionId: UUID) {
require(actor)
val validRefs = dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM organization.department WHERE tenant_id=? AND id=? AND status='ACTIVE') AND EXISTS(SELECT 1 FROM organization.position WHERE tenant_id=? AND id=? AND status='ACTIVE') AS ok", actor.tenantId, departmentId, actor.tenantId, positionId)?.get("ok", Boolean::class.java) == true
if (!validRefs) throw ApiException(HttpStatus.BAD_REQUEST, "ASSIGNMENT_REFERENCE_INVALID", "部门或岗位无效")
dsl.execute("UPDATE organization.user_assignment SET effective_until=CURRENT_TIMESTAMP,is_primary=FALSE WHERE tenant_id=? AND user_id=? AND is_primary=TRUE AND effective_until IS NULL", actor.tenantId, userId)
dsl.execute("INSERT INTO organization.user_assignment(id,tenant_id,user_id,department_id,position_id,is_primary) VALUES(?,?,?,?,?,TRUE)", UuidV7.generate(), actor.tenantId, userId, departmentId, positionId)
audit.recordSuccess(actor, "USER_ASSIGNMENT_REPLACED", "USER", userId.toString(), null, mapOf("departmentId" to departmentId, "positionId" to positionId))
}
private fun require(actor: CurrentUser) = actor.requirePermission(ToolPermission.ORGANIZATION_MANAGE_TENANT)
private fun valid(value: String, max: Int) = value.trim().takeIf { it.isNotEmpty() && it.length <= max && it.matches(Regex("[A-Za-z0-9._-]+|[\\p{L}0-9 ._-]+")) } ?: throw ApiException(HttpStatus.BAD_REQUEST, "VALUE_INVALID", "输入值无效")
}
@@ -0,0 +1,24 @@
package com.all8ai.aioa.admin.organization
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/organization")
class AdminOrganizationController(private val users: CurrentUserService, private val service: AdminOrganizationService) {
private fun actor(jwt: Jwt) = users.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
@GetMapping("/departments") fun departments(@AuthenticationPrincipal jwt: Jwt) = service.departments(actor(jwt))
@PostMapping("/departments") fun createDepartment(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody body: DepartmentCommand) = service.createDepartment(actor(jwt), body.code, body.name)
@GetMapping("/roles") fun roles(@AuthenticationPrincipal jwt: Jwt) = service.roles(actor(jwt))
@GetMapping("/users") fun listUsers(@AuthenticationPrincipal jwt: Jwt) = service.users(actor(jwt))
@PutMapping("/users/{id}/roles") fun roles(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: RolesCommand) = service.replaceRoles(actor(jwt), id, body.roles)
@PutMapping("/users/{id}/assignment") fun assign(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: AssignmentCommand) = service.assign(actor(jwt), id, body.departmentId, body.positionId)
}
data class DepartmentCommand(@field:NotBlank @field:Size(max=64) val code:String,@field:NotBlank @field:Size(max=200) val name:String)
data class RolesCommand(val roles:Set<String>)
data class AssignmentCommand(val departmentId:UUID,val positionId:UUID)
@@ -0,0 +1,36 @@
package com.all8ai.aioa.admin.workflow
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import org.flowable.engine.HistoryService
import org.flowable.engine.RepositoryService
import org.flowable.engine.RuntimeService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.time.Instant
data class ProcessDefinitionView(val id:String,val key:String,val name:String?,val version:Int,val deploymentId:String,val suspended:Boolean)
data class ProcessInstanceView(val id:String,val definitionId:String,val businessKey:String?,val startedAt:Instant?,val endedAt:Instant?,val active:Boolean)
@RestController @RequestMapping("/api/v1/admin/workflows")
class AdminWorkflowController(
private val currentUsers:CurrentUserService, private val repository:RepositoryService,
private val runtime:RuntimeService, private val history:HistoryService,
) {
private fun actor(jwt:Jwt):CurrentUser=currentUsers.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.WORKFLOW_READ_TENANT) }
@GetMapping("/definitions") fun definitions(@AuthenticationPrincipal jwt:Jwt):List<ProcessDefinitionView> {
actor(jwt); return repository.createProcessDefinitionQuery().orderByProcessDefinitionKey().asc().orderByProcessDefinitionVersion().desc().list()
.map { ProcessDefinitionView(it.id,it.key,it.name,it.version,it.deploymentId,it.isSuspended) }
}
@GetMapping("/instances") fun instances(@AuthenticationPrincipal jwt:Jwt,@RequestParam(defaultValue="100") limit:Int):List<ProcessInstanceView> {
val actor=actor(jwt); val active=runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).listPage(0,limit.coerceIn(1,200))
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,null,null,true) }
if(active.size>=limit) return active
val ended=history.createHistoricProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).finished().orderByProcessInstanceEndTime().desc().listPage(0,(limit-active.size).coerceIn(0,200))
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,it.startTime?.toInstant(),it.endTime?.toInstant(),false) }
return active+ended
}
}
@@ -25,6 +25,8 @@ import java.time.Duration
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
@Service
class LeaveRequestService(
@@ -33,6 +35,7 @@ class LeaveRequestService(
private val routingRepository: ApprovalRoutingRepository,
private val workflowGateway: LeaveWorkflowGateway,
private val notificationService: NotificationService? = null,
private val processBindingRouter: ProcessBindingRouter? = null,
) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
@@ -86,6 +89,13 @@ class LeaveRequestService(
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val processDefinitionKey = processBindingRouter?.select(
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
)?.processDefinitionKey ?: if (processBindingRouter == null) {
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
} else {
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
}
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
if (approverId == actor.id) {
@@ -121,7 +131,8 @@ class LeaveRequestService(
)
if (outcome.replayed) return outcome.leaveRequest
val process = workflowGateway.startLeaveApproval(
val process = workflowGateway.startLeaveApprovalWithDefinition(
processDefinitionKey,
actor.tenantId,
outcome.leaveRequest.id,
actor.id,
@@ -0,0 +1,25 @@
package com.all8ai.aioa.audit.api
import com.all8ai.aioa.audit.application.AuditQueryService
import com.all8ai.aioa.audit.application.RedactedAuditEvent
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/admin/audit-events")
class AuditQueryController(private val currentUserService: CurrentUserService, private val service: AuditQueryService) {
@GetMapping
fun list(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) traceId: String?,
@RequestParam(required = false) action: String?,
@RequestParam(required = false) resourceType: String?,
@RequestParam(defaultValue = "100") @Min(1) @Max(200) limit: Int,
): List<RedactedAuditEvent> = service.list(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), traceId, action, resourceType, limit,
)
}
@@ -0,0 +1,44 @@
package com.all8ai.aioa.audit.application
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
@Service
class AuditQueryService(private val repository: AuditQueryRepository) {
fun list(actor: CurrentUser, traceId: String?, action: String?, resourceType: String?, limit: Int): List<RedactedAuditEvent> {
actor.requirePermission(ToolPermission.AUDIT_READ_TENANT_REDACTED)
if (limit !in 1..200) throw ApiException(HttpStatus.BAD_REQUEST, "AUDIT_LIMIT_INVALID", "查询数量必须为 1 到 200")
val normalizedTrace = normalize(traceId, 128, "AUDIT_TRACE_ID_INVALID")
val normalizedAction = normalize(action, 120, "AUDIT_ACTION_INVALID")
val normalizedType = normalize(resourceType, 120, "AUDIT_RESOURCE_TYPE_INVALID")
return repository.list(actor.tenantId, normalizedTrace, normalizedAction, normalizedType, limit).map { event ->
RedactedAuditEvent(event.id, event.actorId, event.action, event.resourceType, event.resourceId, event.traceId,
event.result, event.occurredAt, event.details.filterKeys(SAFE_DETAIL_KEYS::contains))
}
}
private fun normalize(value: String?, max: Int, code: String): String? {
if (value == null) return null
val normalized = value.trim()
if (normalized.isEmpty() || normalized.length > max || !normalized.matches(Regex("[A-Za-z0-9._:-]+"))) {
throw ApiException(HttpStatus.BAD_REQUEST, code, "审计查询条件无效")
}
return normalized
}
companion object {
private val SAFE_DETAIL_KEYS = setOf("model", "promptLength", "clarificationCount", "requestId", "taskId", "decision", "processEnded", "fromStatus", "toStatus", "version", "leaveRequestId", "sizeBytes", "contentType", "platform")
}
}
data class RedactedAuditEvent(
val id: UUID, val actorId: UUID, val action: String, val resourceType: String, val resourceId: String?,
val traceId: String, val result: String, val occurredAt: Instant, val details: Map<String, Any?>,
)
@@ -1,6 +1,7 @@
package com.all8ai.aioa.audit.domain
import java.util.UUID
import java.time.Instant
data class AuditEvent(
val id: UUID,
@@ -18,3 +19,26 @@ data class AuditEvent(
fun interface AuditEventRepository {
fun append(event: AuditEvent)
}
data class StoredAuditEvent(
val id: UUID,
val tenantId: UUID,
val actorId: UUID,
val action: String,
val resourceType: String,
val resourceId: String?,
val traceId: String,
val result: String,
val occurredAt: Instant,
val details: Map<String, Any?>,
)
interface AuditQueryRepository {
fun list(
tenantId: UUID,
traceId: String?,
action: String?,
resourceType: String?,
limit: Int,
): List<StoredAuditEvent>
}
@@ -2,15 +2,20 @@ package com.all8ai.aioa.audit.infrastructure
import com.all8ai.aioa.audit.domain.AuditEvent
import com.all8ai.aioa.audit.domain.AuditEventRepository
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.audit.domain.StoredAuditEvent
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
import org.jooq.impl.DSL
@Repository
class JooqAuditEventRepository(
private val dsl: DSLContext,
private val objectMapper: ObjectMapper,
) : AuditEventRepository {
) : AuditEventRepository, AuditQueryRepository {
override fun append(event: AuditEvent) {
dsl.execute(
"""
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
objectMapper.writeValueAsString(event.details),
)
}
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
val table = DSL.table(DSL.name("audit", "event"))
var condition = DSL.field(DSL.name("tenant_id"), UUID::class.java).eq(tenantId)
traceId?.let { condition = condition.and(DSL.field(DSL.name("trace_id"), String::class.java).eq(it)) }
action?.let { condition = condition.and(DSL.field(DSL.name("action"), String::class.java).eq(it)) }
resourceType?.let { condition = condition.and(DSL.field(DSL.name("resource_type"), String::class.java).eq(it)) }
return dsl.select().from(table).where(condition)
.orderBy(DSL.field(DSL.name("occurred_at")).desc()).limit(limit).fetch().map { record ->
@Suppress("UNCHECKED_CAST")
StoredAuditEvent(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("actor_id", UUID::class.java)!!,
record.get("action", String::class.java)!!,
record.get("resource_type", String::class.java)!!,
record.get("resource_id", String::class.java),
record.get("trace_id", String::class.java)!!,
record.get("result", String::class.java)!!,
record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(),
objectMapper.readValue(record.get("details")!!.toString(), Map::class.java) as Map<String, Any?>,
)
}
}
}
@@ -1,76 +1,64 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/form-definitions")
class FormDefinitionController {
class FormDefinitionController(
private val currentUsers: CurrentUserService? = null,
private val dsl: DSLContext? = null,
private val objectMapper: ObjectMapper? = null,
) {
@GetMapping("/{formKey}")
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
return find(actor.tenantId, formKey) ?: notFound()
}
// 保留纯单元测试与离线内置定义入口;生产 HTTP 始终读取已发布数据库版本。
fun getDefinition(formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> notFound()
}
private fun find(tenantId: UUID, key: String): FormDefinitionResponse? = dsl!!.fetchOne(
"SELECT * FROM form.definition WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",
tenantId, key,
)?.let {
@Suppress("UNCHECKED_CAST")
FormDefinitionResponse(
it.get("form_key", String::class.java)!!,
it.get("version", Int::class.java)!!,
objectMapper!!.readValue(it.get("data_schema")!!.toString(), Map::class.java) as Map<String, Any>,
objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map<String, Any>,
)
}
private fun notFound(): Nothing = throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
companion object {
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
val leaveRequestDefinition = FormDefinitionResponse(
key = LEAVE_REQUEST_FORM_KEY,
version = 1,
dataSchema = mapOf(
"\$id" to "leave-request-v1",
"title" to "请假申请",
"type" to "object",
"required" to listOf("type", "startsAt", "endsAt", "reason"),
"properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
),
),
uiSchema = mapOf(
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
"sections" to listOf(
mapOf(
"title" to "请假信息",
"controls" to listOf(
mapOf(
"field" to "type",
"label" to "请假类型",
"control" to "select",
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
),
),
mapOf(
"title" to "补充说明",
"controls" to listOf(
mapOf(
"field" to "reason",
"label" to "请假原因",
"control" to "textArea",
"placeholder" to "请简要说明请假原因",
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
),
),
),
),
),
LEAVE_REQUEST_FORM_KEY, 1,
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
mapOf("title" to "请假信息", "controls" to listOf(
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
)
}
}
data class FormDefinitionResponse(
val key: String,
val version: Int,
val dataSchema: Map<String, Any>,
val uiSchema: Map<String, Any>,
)
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
@@ -13,9 +13,14 @@ enum class ToolPermission {
AI_LEAVE_PROGRESS_READ_OWN,
APPROVAL_TASK_READ_ASSIGNED,
APPROVAL_TASK_DECIDE_ASSIGNED,
AUDIT_READ_TENANT_REDACTED,
ORGANIZATION_MANAGE_TENANT,
WORKFLOW_READ_TENANT,
OPERATIONS_METRICS_READ_TENANT,
PROCESS_CONFIGURATION_MANAGE_TENANT,
}
enum class DataScope { OWN, ASSIGNED }
enum class DataScope { OWN, ASSIGNED, TENANT }
data class UserCapabilities(
val permissions: Set<ToolPermission>,
@@ -41,12 +46,20 @@ object AuthorizationPolicy {
val permissions = buildSet {
if ("employee" in user.roles) addAll(employeePermissions)
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
if ("oa_admin" in user.roles) addAll(setOf(
ToolPermission.AUDIT_READ_TENANT_REDACTED,
ToolPermission.ORGANIZATION_MANAGE_TENANT,
ToolPermission.WORKFLOW_READ_TENANT,
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
))
}
return UserCapabilities(
permissions,
buildSet {
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
if (permissions.any { it.name.endsWith("_TENANT") }) add(DataScope.TENANT)
},
)
}
@@ -5,16 +5,31 @@ import org.springframework.context.annotation.Configuration
import org.springframework.security.config.Customizer.withDefaults
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
class SecurityConfiguration {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.csrf { it.disable() }
.cors(withDefaults())
.authorizeHttpRequests {
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt(withDefaults()) }
.build()
@Bean
fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", CorsConfiguration().apply {
allowedOrigins = listOf("http://localhost:5173", "http://127.0.0.1:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Authorization", "Content-Type", "X-AIOA-Device-Id", "Idempotency-Key", "X-Trace-Id")
exposedHeaders = listOf("X-Trace-Id", "X-AIOA-Auth-Error")
allowCredentials = true
})
}
}
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
leaveType: String,
): StartedProcess
fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
fun resolveTask(taskId: String): WorkflowTask?
@@ -0,0 +1,20 @@
package com.all8ai.aioa.workflow.domain
import java.util.UUID
data class ProcessBinding(
val id: UUID,
val businessType: String,
val formKey: String,
val formVersion: Int,
val processDefinitionKey: String,
val leaveType: String?,
val minDurationMinutes: Long?,
val maxDurationMinutes: Long?,
val priority: Int,
val status: String,
)
fun interface ProcessBindingRouter {
fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding?
}
@@ -27,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
override fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY)
.processDefinitionKey(processDefinitionKey)
.businessKey(leaveRequestId.toString())
.variables(
mapOf(
@@ -0,0 +1,31 @@
package com.all8ai.aioa.workflow.infrastructure
import com.all8ai.aioa.workflow.domain.ProcessBinding
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.util.UUID
@Repository
class JooqProcessBindingRouter(private val dsl: DSLContext) : ProcessBindingRouter {
override fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding? =
dsl.fetchOne(
"""
SELECT * FROM workflow.process_binding
WHERE tenant_id=? AND business_type=? AND status='ACTIVE'
AND (leave_type IS NULL OR leave_type=?)
AND (min_duration_minutes IS NULL OR min_duration_minutes<=?)
AND (max_duration_minutes IS NULL OR max_duration_minutes>=?)
ORDER BY priority DESC,
(CASE WHEN leave_type IS NULL THEN 0 ELSE 1 END) DESC,
(CASE WHEN min_duration_minutes IS NULL AND max_duration_minutes IS NULL THEN 0 ELSE 1 END) DESC
LIMIT 1
""".trimIndent(), tenantId, businessType, leaveType, durationMinutes, durationMinutes,
)?.let { ProcessBinding(
it.get("id", UUID::class.java)!!, it.get("business_type", String::class.java)!!,
it.get("form_key", String::class.java)!!, it.get("form_version", Int::class.java)!!,
it.get("process_definition_key", String::class.java)!!, it.get("leave_type", String::class.java),
it.get("min_duration_minutes", Long::class.java), it.get("max_duration_minutes", Long::class.java),
it.get("priority", Int::class.java)!!, it.get("status", String::class.java)!!,
) }
}
@@ -0,0 +1,58 @@
CREATE TABLE form.definition (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
form_key VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
data_schema JSONB NOT NULL,
ui_schema JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, form_key, version),
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
);
CREATE UNIQUE INDEX uq_form_definition_published
ON form.definition (tenant_id, form_key)
WHERE status = 'PUBLISHED';
CREATE TABLE workflow.process_binding (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
business_type VARCHAR(100) NOT NULL,
form_key VARCHAR(100) NOT NULL,
form_version INTEGER NOT NULL,
process_definition_key VARCHAR(100) NOT NULL,
leave_type VARCHAR(32),
min_duration_minutes BIGINT,
max_duration_minutes BIGINT,
priority INTEGER NOT NULL DEFAULT 100,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
REFERENCES form.definition(tenant_id, form_key, version),
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT ck_process_binding_duration CHECK (
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
)
);
CREATE INDEX idx_process_binding_route
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
VALUES (
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
CURRENT_TIMESTAMP
);
INSERT INTO workflow.process_binding (
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
) VALUES (
'70000000-0000-7000-8000-000000000001',
'00000000-0000-7000-8000-000000000001',
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
);
@@ -0,0 +1,15 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApprovalRuleCatalogTest {
@Test fun `catalog exposes only supported runtime variables and safe empty policy`() {
val catalog=approvalRuleCatalog()
assertEquals(setOf("approverId","oaAdministratorId","hrReviewerId"),catalog.map{it.variable}.toSet())
assertTrue(catalog.all{it.emptyPolicy=="REJECT_SUBMISSION"})
assertTrue(catalog.any{it.sourceType=="POSITION"&&it.scope=="APPLICANT_DEPARTMENT"})
assertTrue(catalog.any{it.sourceType=="ROLE"&&it.scope=="TENANT"})
}
}
@@ -0,0 +1,19 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ProcessConfigurationValidationTest {
@Test fun `accepts open and ordered duration ranges`() {
assertTrue(isValidDurationRange(null,null))
assertTrue(isValidDurationRange(0,480))
assertTrue(isValidDurationRange(1440,null))
}
@Test fun `rejects negative or reversed duration ranges`() {
assertFalse(isValidDurationRange(-1,480))
assertFalse(isValidDurationRange(960,480))
assertFalse(isValidDurationRange(0,-1))
}
}
@@ -0,0 +1,65 @@
package com.all8ai.aioa.admin.configuration
import org.flowable.engine.ProcessEngine
import org.flowable.engine.ProcessEngineConfiguration
import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ProcessTemplateExecutionTest {
private lateinit var engine:ProcessEngine
private val manager=ApprovalStepCommand("主管审批","approverId")
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
private val hr=ApprovalStepCommand("HR 复核","hrReviewerId")
private val variables=mapOf(
"approverId" to "manager", "oaAdministratorId" to "oa", "hrReviewerId" to "hr",
"businessId" to "00000000-0000-7000-8000-000000000001",
)
@BeforeEach fun startEngine() {
engine=StandaloneInMemProcessEngineConfiguration()
.setJdbcUrl("jdbc:h2:mem:aioa-${System.nanoTime()};DB_CLOSE_DELAY=-1")
.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE)
.buildProcessEngine()
}
@AfterEach fun stopEngine(){engine.close()}
@Test fun `conditional template skips or creates second approval by duration`() {
deploy(ProcessTemplateCommand("conditionalRuntime","条件流程","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
val short=start("conditionalRuntime",variables+mapOf("durationMinutes" to 480L))
completeOnlyTask(short,"manager",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(short).singleResult())
val long=start("conditionalRuntime",variables+mapOf("durationMinutes" to 1440L))
completeOnlyTask(long,"manager",true)
assertNotNull(engine.taskService.createTaskQuery().processInstanceId(long).taskAssignee("oa").singleResult())
completeOnlyTask(long,"oa",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(long).singleResult())
}
@Test fun `parallel template waits for every approval and terminates on rejection`() {
deploy(ProcessTemplateCommand("parallelRuntime","并行流程","PARALLEL",listOf(oa,hr)))
val approved=start("parallelRuntime",variables)
assertEquals(2,engine.taskService.createTaskQuery().processInstanceId(approved).count())
completeOnlyTask(approved,"oa",true)
assertNotNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
completeOnlyTask(approved,"hr",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
val rejected=start("parallelRuntime",variables)
completeOnlyTask(rejected,"oa",false)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(rejected).singleResult())
assertEquals(0,engine.taskService.createTaskQuery().processInstanceId(rejected).count())
}
private fun deploy(command:ProcessTemplateCommand){engine.repositoryService.createDeployment().addString("${command.key}.bpmn20.xml",generateProcessTemplate(command)).deploy()}
private fun start(key:String,vars:Map<String,Any>)=engine.runtimeService.startProcessInstanceByKey(key,vars).id
private fun completeOnlyTask(instanceId:String,assignee:String,approved:Boolean){
val task=engine.taskService.createTaskQuery().processInstanceId(instanceId).taskAssignee(assignee).singleResult()
assertNotNull(task);engine.taskService.complete(task.id,mapOf("approved" to approved))
}
}
@@ -0,0 +1,30 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFails
class ProcessTemplateGeneratorTest {
private val manager=ApprovalStepCommand("主管审批","approverId")
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
@Test fun `generates serial approval with rejection path`() {
val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
assertContains(xml,"targetRef=\"rejectedEnd\"")
}
@Test fun `generates parallel gateways and condition expressions`() {
val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
assertContains(conditional,"durationMinutes <= 960")
}
@Test fun `rejects unsafe identifiers and assignee expressions`() {
assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
assertFails { generateProcessTemplate(ProcessTemplateCommand("duplicateParallel","测试","PARALLEL",listOf(manager,manager))) }
}
}
@@ -0,0 +1,40 @@
package com.all8ai.aioa.audit.application
import com.all8ai.aioa.audit.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import java.time.Instant
import java.util.UUID
class AuditQueryServiceTest {
@Test
fun `oa administrator receives tenant audit with sensitive details removed`() {
val actor = user(setOf("employee", "oa_admin"))
val stored = StoredAuditEvent(UUID.randomUUID(), actor.tenantId, UUID.randomUUID(), "LEAVE_REQUEST_APPROVED", "LEAVE_REQUEST", "leave-1", "trace-12345678", "SUCCESS", Instant.now(), mapOf("decision" to "APPROVED", "comment" to "敏感审批意见", "prompt" to "敏感提示"))
val repository = object : AuditQueryRepository {
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
assertThat(tenantId).isEqualTo(actor.tenantId)
return listOf(stored)
}
}
val result = AuditQueryService(repository).list(actor, null, null, null, 100).single()
assertThat(result.details).containsEntry("decision", "APPROVED")
assertThat(result.details).doesNotContainKeys("comment", "prompt")
}
@Test
fun `ordinary employee cannot query tenant audit`() {
val service = AuditQueryService(object : AuditQueryRepository {
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int) = emptyList<StoredAuditEvent>()
})
assertThatThrownBy { service.list(user(setOf("employee")), null, null, null, 100) }
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
}
private fun user(roles: Set<String>) = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles)
}
@@ -33,6 +33,19 @@ class AuthorizationPolicyTest {
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
}
@Test
fun `only oa administrator receives tenant management tools`() {
val admin = AuthorizationPolicy.capabilities(user(setOf("employee", "oa_admin")))
val manager = AuthorizationPolicy.capabilities(user(setOf("employee", "department_manager")))
assertThat(admin.permissions).contains(
ToolPermission.ORGANIZATION_MANAGE_TENANT,
ToolPermission.WORKFLOW_READ_TENANT,
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
)
assertThat(admin.dataScopes).contains(DataScope.TENANT)
assertThat(manager.permissions).doesNotContain(ToolPermission.ORGANIZATION_MANAGE_TENANT)
}
private fun user(roles: Set<String>) = CurrentUser(
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
)
+46 -3
View File
@@ -1,12 +1,55 @@
openapi: 3.1.0
info:
title: AIOA API
version: 0.12.0
version: 0.16.0
servers:
- url: /api/v1
security:
- bearerAuth: []
paths:
/admin/process-configuration/forms:
get: { operationId: listFormVersions, summary: 查询表单版本与发布状态, responses: { "200": { description: 表单版本列表 } } }
post: { operationId: createFormVersion, summary: 创建安全校验后的表单草稿版本, responses: { "200": { description: 已创建表单草稿版本 } } }
/admin/process-configuration/forms/{key}/{version}/publish:
post: { operationId: publishFormVersion, summary: 发布表单版本并退役旧发布版本, parameters: [{ name: key, in: path, required: true, schema: { type: string } }, { name: version, in: path, required: true, schema: { type: integer } }], responses: { "200": { description: 已发布 } } }
/admin/process-configuration/bindings:
get: { operationId: listProcessBindings, summary: 查询业务表单与流程绑定, responses: { "200": { description: 流程绑定列表 } } }
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
/admin/process-configuration/bindings/{id}/status:
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
/admin/process-configuration/processes/deploy:
post: { operationId: deployProcessTemplate, summary: 校验并部署受约束的可视化流程模板, responses: { "200": { description: Flowable 流程定义已部署 } } }
/admin/process-configuration/approver-rules:
get: { operationId: listApproverRules, summary: 查询流程设计器可用的组织审批人规则, responses: { "200": { description: 审批人规则目录 } } }
/admin/organization/departments:
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
/admin/organization/roles:
get: { operationId: listAdminRoles, summary: OA 管理员查询租户角色, responses: { "200": { description: 角色列表 } } }
/admin/organization/users:
get: { operationId: listAdminUsers, summary: OA 管理员查询租户用户与任职角色, responses: { "200": { description: 用户列表 } } }
/admin/organization/users/{id}/roles:
put: { operationId: replaceAdminUserRoles, summary: OA 管理员替换用户角色, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 角色已更新 } } }
/admin/organization/users/{id}/assignment:
put: { operationId: replaceAdminUserAssignment, summary: OA 管理员替换用户主任职, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 任职已更新 } } }
/admin/workflows/definitions:
get: { operationId: listWorkflowDefinitions, summary: 查询流程定义及版本, responses: { "200": { description: 流程定义列表 } } }
/admin/workflows/instances:
get: { operationId: listTenantWorkflowInstances, summary: 查询当前租户流程实例, responses: { "200": { description: 流程实例列表 } } }
/admin/metrics:
get: { operationId: getTenantOperationsMetrics, summary: 查询业务流程推送运行指标, responses: { "200": { description: 聚合运行指标 } } }
/admin/audit-events:
get:
operationId: listRedactedTenantAuditEvents
summary: OA 管理员按租户脱敏查询审计记录
parameters:
- { name: traceId, in: query, schema: { type: string, maxLength: 128 } }
- { name: action, in: query, schema: { type: string, maxLength: 120 } }
- { name: resourceType, in: query, schema: { type: string, maxLength: 120 } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
responses:
"200": { description: 当前租户的脱敏审计记录 }
"403": { description: 仅 OA 管理员可查询 }
/devices/register:
post:
operationId: registerCurrentDevice
@@ -631,11 +674,11 @@ components:
uniqueItems: true
items:
type: string
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED]
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED, AUDIT_READ_TENANT_REDACTED, ORGANIZATION_MANAGE_TENANT, WORKFLOW_READ_TENANT, OPERATIONS_METRICS_READ_TENANT, PROCESS_CONFIGURATION_MANAGE_TENANT]
dataScopes:
type: array
uniqueItems: true
items: { type: string, enum: [OWN, ASSIGNED] }
items: { type: string, enum: [OWN, ASSIGNED, TENANT] }
OrganizationRef:
type: object
required: [id, name]
+27
View File
@@ -42,6 +42,33 @@
}
}
]
},
{
"clientId": "aioa-admin-web",
"name": "AIOA Admin Web",
"enabled": true,
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": false,
"redirectUris": ["http://localhost:5173/*", "http://127.0.0.1:5173/*"],
"webOrigins": ["http://localhost:5173", "http://127.0.0.1:5173"],
"attributes": { "pkce.code.challenge.method": "S256" },
"protocolMappers": [
{
"name": "tenant-id",
"protocol": "openid-connect",
"protocolMapper": "oidc-usermodel-attribute-mapper",
"consentRequired": false,
"config": {
"user.attribute": "tenant_id",
"claim.name": "tenant_id",
"jsonType.label": "String",
"id.token.claim": "true",
"access.token.claim": "true",
"userinfo.token.claim": "true"
}
}
]
}
],
"users": [
+51
View File
@@ -0,0 +1,51 @@
# iOS MVP 前端业务测试方案
## 测试目标
在真实 iOS Simulator、Keycloak、PostgreSQL、Flowable 和 Kotlin 后端上验证 Flutter MVP 业务闭环,而不是只验证孤立 Widget。
## 自动化场景
### 员工
- 使用临时测试 Token 进入工作台,生产 OIDC 流程不被替换。
- 打开 Schema 动态请假表单,检查类型、开始时间、结束时间、原因及千问表单助手。
- 通过真实 API 创建唯一草稿,在“我的申请”中找到并打开。
- 从申请详情确认提交,验证状态进入“审批中”。
- 查询 AI 流程进度;千问未配置时验证明确的失败降级。
- 检查待办/通知入口与个人中心设备列表。
附件选择器和系统相册属于 iOS 系统 UI,仓库中的附件 Repository、上传进度、类型和幂等测试负责自动化覆盖;真实文件选择纳入人工验收步骤。
### 部门主管
- 测试自动用员工身份创建并提交一条唯一申请。
- 主管进入待办,只定位该申请并批准。
- 验证批准成功提示,避免依赖历史测试数据。
### OA 管理员
- 验证权限驱动的“OA 管理中心”入口。
- 验证业务指标、组织用户与角色、流程定义/实例、脱敏审计四个页面。
## 执行前提
- PostgreSQL 与 Keycloak 容器健康。
- 后端运行在 `http://localhost:8080`
- 已启动一个 iOS Simulator。
- Realm 中仅限本地开发的 employee、manager、admin 用户可用。
## 执行命令
```bash
./scripts/test-ios-mvp.sh
```
脚本不会输出或持久化访问 Token。`AIOA_E2E_ACCESS_TOKEN` 只在显式测试构建中生效,普通 Debug、Release 和商店构建仍使用 OIDC Authorization Code + PKCE。
## 人工补充验收
1. 点击 Keycloak 登录并完成浏览器回调。
2. 从系统文件选择器选择 JPEG、PNG 和 PDF,确认上传进度和附件状态。
3. 在配置 Firebase 的真机上确认前台、后台和点击跳转通知。
4. 在配置千问密钥的环境确认 AI 草稿建议成功,并检查确认卡片不会自动提交。
+43
View File
@@ -0,0 +1,43 @@
# iOS MVP 前端业务测试报告
- 执行日期:2026-07-18
- 设备:iPhone 17 Pro Simulator
- 系统:iOS 26.5
- Flutter:本机 stable
- 后端:本地 Kotlin/Spring Boot
- 基础服务:PostgreSQL 17.5、Keycloak 26.2.5、Flowable 7.2
## 自动化结果
| 场景 | 结果 | 主要验证点 |
|---|---|---|
| 员工 | 通过 | 工作台、Schema 表单、千问入口、真实草稿、我的申请、提交审批、流程状态、AI 降级、通知、设备 |
| 部门主管 | 通过 | 自动构造唯一待办、列表定位、批准、成功反馈 |
| OA 管理员 | 通过 | 权限驱动入口、指标、组织角色、流程定义/实例、脱敏审计 |
最终脚本输出:`All iOS MVP frontend business scenarios passed.`
同时通过:
- Flutter Analyze
- 27 项 Flutter 单元与 Widget 测试
- iOS Simulator 编译、安装和进程启动
- 登录取消与安全存储异常恢复验收
## 测试期间发现并处理
1. Keycloak 登录取消曾直接显示底层 AppAuth 异常,已改为静默取消或简短中文错误。
2. 模拟器重装后安全存储异常曾阻塞登录页,已降级为无本地会话。
3. 通知标签包含动态未读数,集成测试改为语义匹配。
4. 管理员 Token 因整套测试耗时接近过期,脚本改为每个角色执行前即时获取 Token。
5. 主管场景原先可能命中历史待办,现改为自动创建并只处理唯一测试申请。
## 外部配置相关验证
以下能力的代码路径、失败降级和 Repository 已自动化覆盖,但成功投递依赖外部正式凭证或系统 UI:
- 千问真实回答:需要有效且已轮换的 `QWEN_API_KEY`
- APNs/FCM 真机通知:需要 Firebase 项目与 Apple 推送配置。
- 系统文件选择器:需人工选择 JPEG、PNG、PDF 完成真机上传验收。
这些外部条件不影响当前 MVP 前端业务闭环、权限边界和失败处理的自动化通过结果。
+34
View File
@@ -0,0 +1,34 @@
# 表单版本与流程选择
## 发布模型
表单定义以 `tenant + formKey + version` 唯一标识,状态为 `DRAFT``PUBLISHED``RETIRED`。同一租户同一表单只能有一个已发布版本。
流程绑定将以下条件映射到 Flowable Process Definition Key
- 业务类型
- 表单 Key 和版本
- 可选业务枚举,例如请假类型
- 可选最小时长和最大时长
- 优先级
- ACTIVE / INACTIVE 状态
## 运行时选择
Flutter 只能提交业务表单数据,不能提交流程定义 ID。Kotlin 后端按当前租户和权威业务数据查询绑定:
1. 只选择 ACTIVE 绑定。
2. 业务类型必须精确匹配。
3. 枚举和时长条件必须满足。
4. 优先级高的规则优先。
5. 精确枚举或范围规则优先于通配规则。
6. 无匹配绑定时拒绝提交,不启动默认或任意流程。
流程启动后保存实际 `processDefinitionId`,运行中实例不会因之后修改绑定而切换版本。
## 安全边界
- 只有 OA 管理员拥有 `PROCESS_CONFIGURATION_MANAGE_TENANT`
- 新表单版本先进入草稿,发布前执行 Schema 结构校验。
- 绑定只能引用数据库中存在的表单版本和 Flowable 已部署流程 Key。
- 创建、发布、启停均写入审计。
+13
View File
@@ -48,6 +48,19 @@
- [x] 查询本人流程进度
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
## M4:管理与运营闭环
- [x] OA 管理员按租户脱敏查询审计记录
- [x] OA 管理员组织与角色维护 API
- [x] 流程定义、版本和运行实例只读管理
- [x] Flutter 管理工具入口与权限驱动展示
- [x] 业务与推送运行指标仪表板
- [x] 表单版本发布、业务流程绑定与服务端路由选择
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
- [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署
## Definition of Done
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。
+13
View File
@@ -51,3 +51,16 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
```
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
## 可视化流程模板
Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowable BPMN 模板:
- 串行审批:审批节点依次执行,任一节点驳回即终止。
- 并行会签:多个审批节点同时执行,全部通过后汇聚,任一驳回即终止其他分支。
- 条件审批:首个审批通过后按请假时长阈值决定直接结束或增加复核节点。
流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。
审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin``hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。
自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。
@@ -0,0 +1,183 @@
import 'dart:convert';
import 'package:aioa_mobile/app/app.dart';
import 'package:aioa_mobile/app/router.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:integration_test/integration_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('complete MVP frontend business scenario', (tester) async {
final api = _E2eApi(RuntimeConfig.e2eAccessToken);
await api.registerDevice('90000000-0000-4000-8000-000000000001');
await tester.pumpWidget(const ProviderScope(child: AioaApp()));
await tester.pumpAndSettle(const Duration(seconds: 10));
expect(find.text('工作台'), findsOneWidget);
switch (RuntimeConfig.e2eRole) {
case 'manager':
await _managerScenario(tester);
case 'admin':
await _adminScenario(tester);
default:
await _employeeScenario(tester, api);
}
});
}
Future<void> _employeeScenario(WidgetTester tester, _E2eApi api) async {
await tester.tap(find.text('发起请假'));
await tester.pumpAndSettle(const Duration(seconds: 8));
expect(find.text('请假申请'), findsOneWidget);
expect(find.text('千问表单助手'), findsOneWidget);
expect(find.byKey(const ValueKey('type')), findsOneWidget);
expect(find.byKey(const ValueKey('startsAt')), findsOneWidget);
expect(find.byKey(const ValueKey('endsAt')), findsOneWidget);
expect(find.byKey(const ValueKey('reason')), findsOneWidget);
appRouter.go('/workspace');
await tester.pumpAndSettle();
final reason = 'iOS MVP 集成测试 ${DateTime.now().millisecondsSinceEpoch}';
final draft = await api.createDraft(reason);
await tester.tap(find.text('我的请假申请'));
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.textContaining(reason), findsOneWidget);
await tester.tap(find.textContaining(reason));
await tester.pumpAndSettle();
expect(find.text('草稿'), findsOneWidget);
await tester.tap(find.text('提交审批'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '确认'));
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.text('审批中'), findsOneWidget);
appRouter.go('/workspace');
await tester.pumpAndSettle();
await tester.tap(find.text('AI 助手'));
await tester.pumpAndSettle();
expect(find.text('AI 流程助手'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '我最近提交的请假到哪一步了?');
await tester.tap(find.text('查询进度'));
await tester.pumpAndSettle(const Duration(seconds: 8));
expect(find.textContaining('查询失败'), findsWidgets);
await tester.tap(find.text('待办'));
await tester.pumpAndSettle();
expect(find.textContaining('通知'), findsWidgets);
await tester.tap(find.text('我的'));
await tester.pumpAndSettle(const Duration(seconds: 5));
expect(find.text('登录设备'), findsOneWidget);
expect(draft['id'], isNotNull);
}
Future<void> _managerScenario(WidgetTester tester) async {
final employee = _E2eApi(RuntimeConfig.e2eEmployeeToken);
await employee.registerDevice('90000000-0000-4000-8000-000000000002');
final reason = '主管 iOS 审批测试 ${DateTime.now().millisecondsSinceEpoch}';
final draft = await employee.createDraft(
reason,
deviceId: '90000000-0000-4000-8000-000000000002',
);
await employee.submitDraft(
draft,
deviceId: '90000000-0000-4000-8000-000000000002',
);
await tester.tap(find.text('待办'));
await tester.pumpAndSettle(const Duration(seconds: 8));
expect(find.text(reason), findsOneWidget);
final taskCard = find.ancestor(
of: find.text(reason),
matching: find.byType(Card),
);
await tester.tap(find.descendant(of: taskCard, matching: find.text('批准')));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(FilledButton, '确认批准'));
await tester.pumpAndSettle(const Duration(seconds: 8));
expect(find.text('已批准'), findsOneWidget);
}
Future<void> _adminScenario(WidgetTester tester) async {
await tester.tap(find.text('我的'));
await tester.pumpAndSettle(const Duration(seconds: 8));
expect(find.text('OA 管理中心'), findsOneWidget);
await tester.tap(find.text('OA 管理中心'));
await tester.pumpAndSettle(const Duration(seconds: 10));
expect(find.text('指标'), findsOneWidget);
expect(find.text('组织'), findsOneWidget);
expect(find.text('流程'), findsOneWidget);
expect(find.text('审计'), findsOneWidget);
await tester.tap(find.text('组织'));
await tester.pumpAndSettle();
expect(find.text('新增部门'), findsOneWidget);
await tester.tap(find.text('流程'));
await tester.pumpAndSettle();
expect(find.text('流程定义'), findsOneWidget);
await tester.tap(find.text('审计'));
await tester.pumpAndSettle();
expect(find.byType(Card), findsWidgets);
}
class _E2eApi {
_E2eApi(this.token);
final String token;
String get base => RuntimeConfig.apiBaseUrl;
Map<String, String> headers([String? device]) => {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
'X-AIOA-Device-Id': ?device,
};
static const device = '90000000-0000-4000-8000-000000000001';
Future<void> registerDevice(String id) async {
final r = await http.post(
Uri.parse('$base/devices/register'),
headers: headers(),
body: jsonEncode({
'id': id,
'name': 'iOS integration test',
'platform': 'IOS',
}),
);
expect(r.statusCode, 200);
}
Future<Map<String, Object?>> createDraft(
String reason, {
String deviceId = device,
}) async {
final now = DateTime.now().toUtc().add(const Duration(days: 2));
final r = await http.post(
Uri.parse('$base/leave-requests/drafts'),
headers: {
...headers(deviceId),
'Idempotency-Key': 'ios-e2e-${DateTime.now().microsecondsSinceEpoch}',
},
body: jsonEncode({
'type': 'PERSONAL',
'startsAt': now.toIso8601String(),
'endsAt': now.add(const Duration(hours: 4)).toIso8601String(),
'reason': reason,
'version': 0,
}),
);
expect(r.statusCode, 201);
return Map<String, Object?>.from(jsonDecode(r.body) as Map);
}
Future<void> submitDraft(
Map<String, Object?> draft, {
String deviceId = device,
}) async {
final r = await http.post(
Uri.parse('$base/leave-requests/${draft['id']}/submit'),
headers: {
...headers(deviceId),
'Idempotency-Key':
'ios-submit-${DateTime.now().microsecondsSinceEpoch}',
},
body: jsonEncode({'version': draft['version']}),
);
expect(r.statusCode, 200);
}
}
+6
View File
@@ -105,6 +105,8 @@ PODS:
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- integration_test (0.0.1):
- Flutter
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
@@ -126,6 +128,7 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
SPEC REPOS:
@@ -158,6 +161,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/flutter_appauth/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
@@ -178,6 +183,7 @@ SPEC CHECKSUMS:
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
+2
View File
@@ -6,6 +6,7 @@ import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
import 'package:aioa_mobile/features/admin/presentation/admin_page.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@@ -30,6 +31,7 @@ final appRouter = GoRouter(
),
),
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
GoRoute(path: '/admin', builder: (_, _) => const AdminPage()),
GoRoute(
path: '/leave/:id',
builder: (_, state) =>
@@ -19,11 +19,27 @@ class AuthSessionController extends AsyncNotifier<AuthSession?> {
static const _appAuth = FlutterAppAuth();
@override
Future<AuthSession?> build() => _restore();
Future<AuthSession?> build() async {
if (RuntimeConfig.e2eAccessToken.isNotEmpty) {
return AuthSession(
accessToken: RuntimeConfig.e2eAccessToken,
refreshToken: null,
idToken: null,
expiresAt: DateTime.now().toUtc().add(const Duration(hours: 1)),
);
}
try {
return await _restore();
} catch (_) {
// 安全存储在模拟器重装、系统升级或钥匙串状态变化后可能暂时不可读。
// 将其视为无本地会话,避免阻断用户重新登录。
return null;
}
}
Future<void> login() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
try {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
oidcClientId,
@@ -34,8 +50,12 @@ class AuthSessionController extends AsyncNotifier<AuthSession?> {
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(result);
});
state = AsyncData(await _storeResponse(result));
} on FlutterAppAuthUserCancelledException {
state = const AsyncData(null);
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
}
}
Future<void> logout() async {
+1 -1
View File
@@ -39,7 +39,7 @@ class LoginPage extends ConsumerWidget {
if (auth.hasError) ...[
const SizedBox(height: 16),
Text(
'登录失败${auth.error}',
'登录失败,请检查网络后重试',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
@@ -7,6 +7,11 @@ class RuntimeConfig {
static const _configuredOidcIssuer = String.fromEnvironment(
'AIOA_OIDC_ISSUER',
);
static const e2eAccessToken = String.fromEnvironment('AIOA_E2E_ACCESS_TOKEN');
static const e2eEmployeeToken = String.fromEnvironment(
'AIOA_E2E_EMPLOYEE_TOKEN',
);
static const e2eRole = String.fromEnvironment('AIOA_E2E_ROLE');
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
? _configuredApiBaseUrl
@@ -98,6 +98,7 @@ class DynamicFormCard extends StatelessWidget {
onChanged: (value) => onChanged(control.field, value),
),
FormControlType.dateTime => _DateTimeControl(
key: ValueKey(control.field),
label: label,
value: state.values[control.field] as String?,
errorText: error,
@@ -109,6 +110,7 @@ class DynamicFormCard extends StatelessWidget {
class _DateTimeControl extends StatelessWidget {
const _DateTimeControl({
super.key,
required this.label,
required this.value,
required this.errorText,
@@ -0,0 +1,43 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final adminRepositoryProvider = Provider(
(ref) => AdminRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final currentPermissionsProvider = FutureProvider(
(ref) => ref.watch(adminRepositoryProvider).permissions(),
);
final adminDashboardProvider =
AsyncNotifierProvider<AdminController, Map<String, Object?>>(
AdminController.new,
);
class AdminController extends AsyncNotifier<Map<String, Object?>> {
@override
Future<Map<String, Object?>> build() =>
ref.read(adminRepositoryProvider).dashboard();
Future<String?> createDepartment(String code, String name) async {
try {
await ref.read(adminRepositoryProvider).createDepartment(code, name);
ref.invalidateSelf();
return null;
} catch (e) {
return '$e';
}
}
Future<String?> replaceRoles(String id, Set<String> roles) async {
try {
await ref.read(adminRepositoryProvider).replaceRoles(id, roles);
ref.invalidateSelf();
return null;
} catch (e) {
return '$e';
}
}
}
@@ -0,0 +1,76 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AdminRepository {
AdminRepository({required this.client, required this.baseUrl});
final http.Client client;
final String baseUrl;
Future<Set<String>> permissions() async =>
((await _get('/me'))['permissions'] as List).cast<String>().toSet();
Future<Map<String, Object?>> dashboard() async {
final values = await Future.wait([
_get('/admin/metrics'),
_list('/admin/organization/departments'),
_list('/admin/organization/roles'),
_list('/admin/organization/users'),
_list('/admin/workflows/definitions'),
_list('/admin/workflows/instances'),
_list('/admin/audit-events?limit=100'),
_list('/admin/process-configuration/forms'),
_list('/admin/process-configuration/bindings'),
]);
return {
'metrics': values[0],
'departments': values[1],
'roles': values[2],
'users': values[3],
'definitions': values[4],
'instances': values[5],
'audits': values[6],
'formVersions': values[7],
'bindings': values[8],
};
}
Future<void> createDepartment(String code, String name) => _write(
'POST',
'/admin/organization/departments',
{'code': code, 'name': name},
);
Future<void> replaceRoles(String userId, Set<String> roles) => _write(
'PUT',
'/admin/organization/users/$userId/roles',
{'roles': roles.toList()},
);
Future<Map<String, Object?>> _get(String path) async {
final r = await client.get(Uri.parse('$baseUrl$path'));
_ok(r);
return Map<String, Object?>.from(jsonDecode(r.body) as Map);
}
Future<List<Map<String, Object?>>> _list(String path) async {
final r = await client.get(Uri.parse('$baseUrl$path'));
_ok(r);
return (jsonDecode(r.body) as List)
.map((e) => Map<String, Object?>.from(e as Map))
.toList();
}
Future<void> _write(
String method,
String path,
Map<String, Object?> body,
) async {
final request = http.Request(method, Uri.parse('$baseUrl$path'))
..headers['Content-Type'] = 'application/json'
..body = jsonEncode(body);
final r = await http.Response.fromStream(await client.send(request));
_ok(r);
}
void _ok(http.Response r) {
if (r.statusCode < 200 || r.statusCode >= 300) {
throw Exception('管理请求失败(${r.statusCode}');
}
}
}
@@ -0,0 +1,267 @@
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AdminPage extends ConsumerWidget {
const AdminPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(adminDashboardProvider);
return Scaffold(
appBar: AppBar(title: const Text('OA 管理中心')),
body: data.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (error, _) => Center(
child: FilledButton(
onPressed: () => ref.invalidate(adminDashboardProvider),
child: Text('加载失败,点击重试\n$error'),
),
),
data: (value) => DefaultTabController(
length: 4,
child: Column(
children: [
const TabBar(
isScrollable: true,
tabs: [
Tab(text: '指标'),
Tab(text: '组织'),
Tab(text: '流程'),
Tab(text: '审计'),
],
),
Expanded(
child: TabBarView(
children: [
_Metrics(value['metrics']! as Map<String, Object?>),
_Organization(value),
_Workflow(value),
_Audit(value['audits']! as List<Map<String, Object?>>),
],
),
),
],
),
),
),
);
}
}
class _Metrics extends StatelessWidget {
const _Metrics(this.data);
final Map<String, Object?> data;
@override
Widget build(BuildContext context) => GridView.count(
padding: const EdgeInsets.all(12),
crossAxisCount: 2,
childAspectRatio: 1.5,
children: data.entries
.where((entry) => entry.key != 'leaveByStatus')
.map(
(entry) => Card(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'${entry.value}',
style: Theme.of(context).textTheme.headlineSmall,
),
Text(entry.key, textAlign: TextAlign.center),
],
),
),
),
)
.toList(),
);
}
class _Organization extends ConsumerWidget {
const _Organization(this.data);
final Map<String, Object?> data;
@override
Widget build(BuildContext context, WidgetRef ref) {
final users = data['users']! as List<Map<String, Object?>>;
final roles = data['roles']! as List<Map<String, Object?>>;
return ListView(
padding: const EdgeInsets.all(12),
children: [
FilledButton.icon(
onPressed: () => _add(context, ref),
icon: const Icon(Icons.add),
label: const Text('新增部门'),
),
...users.map(
(user) => Card(
child: ListTile(
title: Text('${user['displayName']}'),
subtitle: Text(
'${user['username']} · ${(user['roles'] as List).join('')}',
),
trailing: IconButton(
icon: const Icon(Icons.manage_accounts),
onPressed: () => _roles(context, ref, user, roles),
),
),
),
),
],
);
}
Future<void> _add(BuildContext context, WidgetRef ref) async {
final code = TextEditingController();
final name = TextEditingController();
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('新增部门'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextField(
controller: code,
decoration: const InputDecoration(labelText: '编码'),
),
TextField(
controller: name,
decoration: const InputDecoration(labelText: '名称'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消'),
),
FilledButton(
onPressed: () => Navigator.pop(dialogContext, true),
child: const Text('创建'),
),
],
),
);
if (confirmed == true) {
await ref
.read(adminDashboardProvider.notifier)
.createDepartment(code.text, name.text);
}
code.dispose();
name.dispose();
}
Future<void> _roles(
BuildContext context,
WidgetRef ref,
Map<String, Object?> user,
List<Map<String, Object?>> available,
) async {
final selected = (user['roles'] as List).cast<String>().toSet();
final result = await showDialog<Set<String>>(
context: context,
builder: (dialogContext) => StatefulBuilder(
builder: (context, setState) => AlertDialog(
title: Text('${user['displayName']} 的角色'),
content: Column(
mainAxisSize: MainAxisSize.min,
children: available.map((role) {
final code = '${role['code']}';
return CheckboxListTile(
value: selected.contains(code),
title: Text('${role['name']}'),
onChanged: (checked) => setState(
() => checked == true
? selected.add(code)
: selected.remove(code),
),
);
}).toList(),
),
actions: [
FilledButton(
onPressed: () => Navigator.pop(dialogContext, selected),
child: const Text('保存'),
),
],
),
),
);
if (result != null) {
await ref
.read(adminDashboardProvider.notifier)
.replaceRoles('${user['id']}', result);
}
}
}
class _Workflow extends StatelessWidget {
const _Workflow(this.data);
final Map<String, Object?> data;
@override
Widget build(BuildContext context) => ListView(
padding: const EdgeInsets.all(12),
children: [
const Text('业务流程绑定'),
...(data['bindings']! as List<Map<String, Object?>>).map(
(item) => Card(
child: ListTile(
leading: Icon(
item['status'] == 'ACTIVE' ? Icons.link : Icons.link_off,
),
title: Text(
'${item['business_type']}${item['process_definition_key']}',
),
subtitle: Text(
'表单 ${item['form_key']} v${item['form_version']} · 优先级 ${item['priority']}',
),
),
),
),
const Divider(),
const Text('表单版本'),
...(data['formVersions']! as List<Map<String, Object?>>).map(
(item) => ListTile(
title: Text('${item['form_key']} v${item['version']}'),
subtitle: Text('${item['status']}'),
),
),
const Divider(),
const Text('流程定义'),
...(data['definitions']! as List<Map<String, Object?>>).map(
(item) => ListTile(
title: Text('${item['name'] ?? item['key']} v${item['version']}'),
subtitle: Text('${item['id']}'),
),
),
const Divider(),
const Text('最近实例'),
...(data['instances']! as List<Map<String, Object?>>).map(
(item) => ListTile(
title: Text('${item['businessKey'] ?? item['id']}'),
subtitle: Text(item['active'] == true ? '运行中' : '已结束'),
),
),
],
);
}
class _Audit extends StatelessWidget {
const _Audit(this.items);
final List<Map<String, Object?>> items;
@override
Widget build(BuildContext context) => ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
return Card(
child: ListTile(
title: Text('${item['action']}'),
subtitle: Text('${item['resourceType']} · ${item['traceId']}'),
),
);
},
);
}
@@ -3,6 +3,8 @@ import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
import 'package:intl/intl.dart';
import 'package:aioa_mobile/features/admin/application/admin_controller.dart';
import 'package:go_router/go_router.dart';
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@@ -10,6 +12,8 @@ class ProfilePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final devices = ref.watch(deviceListProvider);
final permissions =
ref.watch(currentPermissionsProvider).value ?? const <String>{};
return ListView(
padding: const EdgeInsets.all(18),
children: [
@@ -21,6 +25,14 @@ class ProfilePage extends ConsumerWidget {
),
),
const SizedBox(height: 12),
if (permissions.contains('ORGANIZATION_MANAGE_TENANT')) ...[
FilledButton.icon(
onPressed: () => context.push('/admin'),
icon: const Icon(Icons.admin_panel_settings),
label: const Text('OA 管理中心'),
),
const SizedBox(height: 12),
],
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
...devices.when(
+39
View File
@@ -246,6 +246,11 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "12.0.1"
flutter_driver:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
@@ -336,6 +341,11 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.0.0"
fuchsia_remote_debug_protocol:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
glob:
dependency: transitive
description:
@@ -384,6 +394,11 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "4.1.2"
integration_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
intl:
dependency: "direct main"
description:
@@ -600,6 +615,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.5.2"
process:
dependency: transitive
description:
name: process
sha256: c6248e4526673988586e8c00bb22a49210c258dc91df5227d5da9748ecf79744
url: "https://pub.flutter-io.cn"
source: hosted
version: "5.0.5"
pub_semver:
dependency: transitive
description:
@@ -773,6 +796,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "1.4.1"
sync_http:
dependency: transitive
description:
name: sync_http
sha256: "7f0cd72eca000d2e026bcd6f990b81d0ca06022ef4e32fb257b30d3d1014a961"
url: "https://pub.flutter-io.cn"
source: hosted
version: "0.3.1"
term_glyph:
dependency: transitive
description:
@@ -869,6 +900,14 @@ packages:
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.0.3"
webdriver:
dependency: transitive
description:
name: webdriver
sha256: "2f3a14ca026957870cfd9c635b83507e0e51d8091568e90129fbf805aba7cade"
url: "https://pub.flutter-io.cn"
source: hosted
version: "3.1.0"
webkit_inspection_protocol:
dependency: transitive
description:
+2
View File
@@ -46,6 +46,8 @@ dependencies:
shared_preferences: ^2.5.3
dev_dependencies:
integration_test:
sdk: flutter
flutter_test:
sdk: flutter
@@ -0,0 +1,26 @@
import 'dart:convert';
import 'package:aioa_mobile/features/admin/data/admin_repository.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
void main() {
test('loads server computed permissions', () async {
final repository = AdminRepository(
baseUrl: 'https://api.test/api/v1',
client: MockClient((request) async {
expect(request.url.path, '/api/v1/me');
return http.Response(
jsonEncode({
'permissions': ['ORGANIZATION_MANAGE_TENANT'],
}),
200,
);
}),
);
expect(
await repository.permissions(),
contains('ORGANIZATION_MANAGE_TENANT'),
);
});
}
+2
View File
@@ -5,3 +5,5 @@
`verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,注册脚本设备并验证健康检查、Token 获取、设备会话和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。
`verify-all.sh` 执行后端测试、AI 服务测试、Flutter 格式/分析/测试、OpenAPI YAML 和 Git 空白检查。设置 `AIOA_FULL_BUILD=1` 后还会构建 Android Debug APK,并在 macOS 上构建 iOS Simulator App。
`test-ios-mvp.sh` 在已启动的 iOS Simulator 上依次执行员工、部门主管和 OA 管理员真实前端业务场景。测试连接本地 Keycloak、后端、PostgreSQL 和 Flowable,并自动构造互相隔离的业务数据。
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
set -euo pipefail
root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
keycloak_url="${KEYCLOAK_URL:-http://localhost:8081}"
backend_url="${BACKEND_URL:-http://localhost:8080}"
device_id="${IOS_DEVICE_ID:-$(xcrun simctl list devices booted -j | jq -r '.devices[][] | select(.state == "Booted") | .udid' | head -1)}"
if [[ -z "${device_id}" ]]; then
echo 'No booted iOS simulator. Boot one or set IOS_DEVICE_ID.' >&2
exit 1
fi
curl --fail --silent "${backend_url}/actuator/health" >/dev/null
token() {
curl --fail --silent --show-error \
-X POST "${keycloak_url}/realms/aioa/protocol/openid-connect/token" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode client_id=aioa-mobile \
--data-urlencode grant_type=password \
--data-urlencode "username=$1" \
--data-urlencode "password=$2" | jq -r .access_token
}
run_role() {
local role="$1"
local access_token="$2"
local employee_access_token="$3"
echo "Running iOS MVP scenario: ${role}"
(
cd "${root_dir}/mobile"
flutter test integration_test/mvp_business_test.dart \
-d "${device_id}" \
--dart-define="AIOA_E2E_ACCESS_TOKEN=${access_token}" \
--dart-define="AIOA_E2E_EMPLOYEE_TOKEN=${employee_access_token}" \
--dart-define="AIOA_E2E_ROLE=${role}" \
--dart-define="AIOA_API_BASE_URL=${backend_url}/api/v1"
)
}
employee_token="$(token employee 'Employee123!')"
run_role employee "${employee_token}" "${employee_token}"
employee_token="$(token employee 'Employee123!')"
manager_token="$(token manager 'Manager123!')"
run_role manager "${manager_token}" "${employee_token}"
admin_token="$(token admin 'Admin123!')"
run_role admin "${admin_token}" "${employee_token}"
echo 'All iOS MVP frontend business scenarios passed.'