Compare commits

..

14 Commits

Author SHA1 Message Date
selfrelease 75bc867e4c chore: gitignore backend/boot/bin/ 2026-07-19 09:38:14 +08:00
selfrelease 57c8a26147 feat: LLM多阶段交互式Schema生成 + UI布局优化
- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文
- Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema
- Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示
- Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列
- Frontend: 流程设计器元数据和模式选择器合并为一行
- Keycloak: 自定义登录主题(中文化)
- 修复CSS媒体查询括号平衡问题
2026-07-19 09:36:42 +08:00
selfrelease a2238bc853 fix: configure JDK 21 toolchain path for Gradle 2026-07-19 08:14:20 +08:00
selfrelease ca42abea74 feat: update process designer, controller, and docs 2026-07-19 07:55:12 +08:00
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
86 changed files with 6146 additions and 74 deletions
+6
View File
@@ -34,3 +34,9 @@ firebase-service-account*.json
.local/
coverage/
reports/
node_modules/
admin-web/dist/
*.tsbuildinfo
admin-web/vite.config.js
admin-web/vite.config.d.ts
backend/boot/bin/
+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"
}
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { api } from './api';
import type { Field, ControlType } from './schema';
export type DesignerStage = 'UNDERSTANDING' | 'CLARIFYING' | 'GENERATING' | 'VALIDATING' | 'CONFIRMING';
export type DesignerSuggestion = {
stage: DesignerStage;
formTitle?: string;
formKey?: string;
fields: {
key: string; label: string; control: string; required: boolean;
placeholder?: string; options?: string[]; helperText?: string;
}[];
process?: {
mode: string;
steps: { name: string; assigneeVariable: string }[];
conditionThresholdDays?: number | null;
};
summary: string;
understanding: string;
assumptions: string[];
needsClarification: string[];
validationIssues?: { field: string; issue: string; severity: string }[];
schemaReady: boolean;
};
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
suggestion?: DesignerSuggestion;
};
export type ApplySuggestion = (s: DesignerSuggestion) => void;
const stageLabels: Record<DesignerStage, string> = {
UNDERSTANDING: '🧠 理解需求',
CLARIFYING: '❓ 澄清确认',
GENERATING: '📋 生成 Schema',
VALIDATING: '✅ 校验 Schema',
CONFIRMING: '🎯 等待确认',
};
export function AiDesignerChat({ onApply, contextLabel, getCurrentSchema }: { onApply: ApplySuggestion; contextLabel: string; getCurrentSchema?: () => string }) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: '你好!我是 AI 设计助手。用自然语言描述你想要的表单或审批流程,我来帮你生成。\n\n例如:\n• "报销申请表单,包含报销人、金额、日期、事由"\n• "报销审批:先部门主管审批,超过5000元加财务复核,最后OA管理员审批"' },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [currentStage, setCurrentStage] = useState<DesignerStage>('UNDERSTANDING');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, loading]);
function buildHistory(msgs: ChatMessage[]) {
return msgs.slice(1).map(m => ({ role: m.role, content: m.content }));
}
async function send(presetText?: string) {
const text = (presetText ?? input).trim();
if (!text || loading) return;
setInput('');
const next = [...messages, { role: 'user' as const, content: text }];
setMessages(next);
setLoading(true);
try {
const result = await api<{ suggestion: DesignerSuggestion; model: string }>('/ai/designer-suggestions', {
method: 'POST',
body: JSON.stringify({
message: text,
history: buildHistory(next.slice(0, -1)),
currentSchema: getCurrentSchema?.() ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
}),
});
const s = result.suggestion;
setCurrentStage(s.stage);
const parts = formatSuggestionMessage(s);
setMessages([...next, { role: 'assistant', content: parts, suggestion: s }]);
} catch (e) {
setMessages([...next, { role: 'assistant', content: `⚠️ ${e instanceof Error ? e.message : 'AI 服务暂时不可用'}` }]);
} finally {
setLoading(false);
}
}
return <div className="aiChatPanel">
<div className="aiChatHeader">
<span> AI </span>
<small>{contextLabel}</small>
<span className="aiStageBadge">{stageLabels[currentStage]}</span>
</div>
<div className="aiChatMessages" ref={scrollRef}>
{messages.map((m, i) => <div key={i} className={`aiMsg ${m.role}`}>
<div className="aiMsgContent">{m.content}</div>
{m.suggestion && m.suggestion.fields.length > 0 && (m.suggestion.stage === 'GENERATING' || m.suggestion.stage === 'VALIDATING' || m.suggestion.stage === 'CONFIRMING') && (
<button className="aiApplyBtn" onClick={() => onApply(m.suggestion!)}> </button>
)}
{m.suggestion && m.suggestion.needsClarification.length > 0 && m.suggestion.stage === 'CLARIFYING' && (
<div className="aiQuickReplies">
{m.suggestion.needsClarification.map((q, qi) => <button key={qi} className="aiQuickReply" onClick={() => void send(q)}>{q}</button>)}
</div>
)}
{m.suggestion && m.suggestion.schemaReady && (
<div className="aiSchemaReady"> Schema </div>
)}
</div>)}
{loading && <div className="aiMsg assistant"><div className="aiMsgContent aiTyping"></div></div>}
</div>
<div className="aiChatInput">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
placeholder={currentStage === 'CONFIRMING' ? '输入"确认"或描述调整…' : '描述你想要的表单或流程…'}
rows={2}
disabled={loading}
/>
<button onClick={() => void send()} disabled={loading || !input.trim()}></button>
</div>
</div>;
}
function formatSuggestionMessage(s: DesignerSuggestion): string {
const parts: string[] = [];
if (s.understanding) parts.push(`🧠 我的理解:${s.understanding}`);
if (s.needsClarification.length > 0) parts.push(`❓ 需要澄清:\n${s.needsClarification.map((q, i) => `${i + 1}. ${q}`).join('\n')}`);
if (s.fields.length > 0) parts.push(`📋 表单字段(${s.fields.length} 个):\n${s.fields.map(f => `${f.label}${f.control}${f.required ? ',必填' : ''}`).join('\n')}`);
if (s.process) parts.push(`🔄 审批流程(${s.process.mode}):${s.process.steps.map(st => st.name).join(' → ')}`);
if (s.validationIssues && s.validationIssues.length > 0) parts.push(`⚠️ 校验问题:\n${s.validationIssues.map(v => ` • [${v.severity}] ${v.field}: ${v.issue}`).join('\n')}`);
if (s.assumptions.length > 0) parts.push(`💡 假设:${s.assumptions.join('')}`);
if (s.summary) parts.push(`📝 ${s.summary}`);
if (s.schemaReady) parts.push(`✅ Schema 已就绪,请确认或调整`);
return parts.join('\n\n') || '已处理';
}
export function suggestionToFields(s: DesignerSuggestion): Field[] {
return s.fields.map(f => ({
id: crypto.randomUUID(),
key: f.key,
label: f.label,
control: (['text', 'textArea', 'select', 'dateTime', 'number'].includes(f.control) ? f.control : 'text') as ControlType,
required: f.required,
placeholder: f.placeholder,
options: f.options,
helperText: f.helperText,
}));
}
+90
View File
@@ -0,0 +1,90 @@
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';
import { AiDesignerChat, suggestionToFields, type DesignerSuggestion } from './AiDesignerChat';
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 [showAI, setShowAI] = useState(false);
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 applySuggestion(s: DesignerSuggestion) {
if (s.formTitle) setTitle(s.formTitle);
if (s.formKey) setFormKey(s.formKey);
if (s.fields.length) { const newFields = suggestionToFields(s); setFields(newFields); setSelected(newFields[0]?.id ?? ''); }
}
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><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={save}>稿</button></div></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 ${showAI ? 'designerWithAI' : ''}`}>
<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>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="表单设计" getCurrentSchema={() => JSON.stringify(schemas)} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify(schemas, null, 2)}</pre></aside>}
<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>}</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>;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { AiDesignerChat, type DesignerSuggestion } from './AiDesignerChat';
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 };
type ProcessHistory = { key: string; version: number; name: string; mode: Mode; status: string; createdAt: string; template: { key: string; name: string; mode: Mode; steps: { name: string; assigneeVariable: Assignee }[]; conditionThreshold: number } };
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 [showAI, setShowAI] = useState(false);
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
const [history, setHistory] = useState<ProcessHistory[]>([]);
async function loadConfiguration() {
try {
const [ruleResult, historyResult] = await Promise.all([
api<ApprovalRule[]>('/admin/process-configuration/approver-rules'),
api<ProcessHistory[]>('/admin/process-configuration/processes'),
]);
setRules(ruleResult); setHistory(historyResult);
} catch (error) { setMessage(error instanceof Error ? error.message : '流程配置加载失败'); }
}
useEffect(() => { void loadConfiguration(); }, []);
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 applySuggestion(s: DesignerSuggestion) {
if (s.process) {
const p = s.process;
const m = (['SERIAL', 'PARALLEL', 'CONDITIONAL'].includes(p.mode) ? p.mode : 'SERIAL') as Mode;
setMode(m);
if (p.steps.length) {
const newSteps = p.steps.map(st => ({ id: crypto.randomUUID(), name: st.name, assigneeVariable: (['approverId', 'oaAdministratorId', 'hrReviewerId'].includes(st.assigneeVariable) ? st.assigneeVariable : 'approverId') as Assignee }));
setSteps(newSteps); setSelected(newSteps[0]?.id ?? '');
}
if (p.conditionThresholdDays != null) setThresholdDays(p.conditionThresholdDays);
}
}
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);
}
function loadTemplate(item: ProcessHistory) {
setKey(item.template.key); setName(item.template.name); setMode(item.template.mode);
const restored = item.template.steps.map(step => ({ id: crypto.randomUUID(), name: step.name, assigneeVariable: step.assigneeVariable }));
setSteps(restored); setSelected(restored[0]?.id ?? ''); setThresholdDays(item.template.conditionThreshold / 480);
setMessage(`${item.key} v${item.version} 已加载,可修改后部署新版本`);
}
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`);
await loadConfiguration();
} 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><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div></div>
<section className="processMetaRow"><div 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></div><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></section>
<div className={`processWorkspace ${showAI ? 'processWorkspaceWithAI' : ''}`}>
<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>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="流程设计" getCurrentSchema={() => JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays })} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays }, null, 2)}</pre></aside>}
<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><div className="processHistory"><h2></h2>{history.length === 0 ? <p></p> : history.map(item => <button key={`${item.key}:${item.version}`} onClick={() => loadTemplate(item)}><span><strong>{item.name}</strong><small>{item.key} · v{item.version} · {item.mode}</small></span><b></b></button>)}</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;
}
+18
View File
@@ -0,0 +1,18 @@
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',
extraQueryParams: { ui_locales: 'zh-CN' },
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');
});
});
+19
View File
@@ -0,0 +1,19 @@
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime' | 'number';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[]; helperText?: 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' }
: field.control === 'number'
? { type: 'number' }
: { 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 } });
+23 -1
View File
@@ -1,13 +1,14 @@
from fastapi import Depends, FastAPI, HTTPException
from app.config import get_settings
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse, DesignerSuggestionRequest, DesignerSuggestionResponse
from app.qwen import (
QwenConfigurationError,
QwenSuggestionGateway,
QwenUpstreamError,
SuggestionGateway,
ProgressGateway,
DesignerGateway,
)
app = FastAPI(title="AIOA AI Service", version="0.1.0")
@@ -21,6 +22,10 @@ def get_progress_gateway() -> ProgressGateway:
return QwenSuggestionGateway(get_settings())
def get_designer_gateway() -> DesignerGateway:
return QwenSuggestionGateway(get_settings())
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@@ -55,3 +60,20 @@ async def answer_leave_progress(
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
@app.post("/v1/designer/suggest", response_model=DesignerSuggestionResponse)
async def suggest_design(
request: DesignerSuggestionRequest,
gateway: DesignerGateway = Depends(get_designer_gateway),
) -> DesignerSuggestionResponse:
try:
suggestion = await gateway.suggest_design(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return DesignerSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
+78
View File
@@ -65,3 +65,81 @@ class LeaveProgressAnswerRequest(BaseModel):
class LeaveProgressAnswerResponse(BaseModel):
answer: str = Field(min_length=1, max_length=2000)
model: str
# ── AI 对话式设计助手(多阶段交互) ──────────────────────────────────
class DesignerStage(StrEnum):
UNDERSTANDING = "UNDERSTANDING"
CLARIFYING = "CLARIFYING"
GENERATING = "GENERATING"
VALIDATING = "VALIDATING"
CONFIRMING = "CONFIRMING"
class DesignerFieldType(StrEnum):
TEXT = "text"
TEXT_AREA = "textArea"
SELECT = "select"
DATE_TIME = "dateTime"
NUMBER = "number"
class DesignerFormField(BaseModel):
key: str = Field(min_length=1, max_length=64)
label: str = Field(min_length=1, max_length=100)
control: DesignerFieldType
required: bool = False
placeholder: str | None = Field(default=None, max_length=200)
options: list[str] | None = Field(default=None, max_length=20)
helperText: str | None = Field(default=None, max_length=200)
class DesignerProcessStep(BaseModel):
name: str = Field(min_length=1, max_length=100)
assigneeVariable: str = Field(min_length=1, max_length=64)
class DesignerProcessSuggestion(BaseModel):
mode: str = Field(default="SERIAL", max_length=20)
steps: list[DesignerProcessStep] = Field(default_factory=list, max_length=10)
conditionThresholdDays: float | None = None
class SchemaValidationIssue(BaseModel):
field: str = Field(min_length=1, max_length=100)
issue: str = Field(min_length=1, max_length=300)
severity: str = Field(default="WARNING", max_length=20)
class DesignerSuggestion(BaseModel):
stage: DesignerStage = DesignerStage.UNDERSTANDING
formTitle: str | None = Field(default=None, max_length=100)
formKey: str | None = Field(default=None, max_length=64)
fields: list[DesignerFormField] = Field(default_factory=list, max_length=30)
process: DesignerProcessSuggestion | None = None
summary: str = Field(default="", max_length=500)
understanding: str = Field(default="", max_length=800)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
validationIssues: list[SchemaValidationIssue] = Field(default_factory=list, max_length=20)
schemaReady: bool = False
class ChatTurn(BaseModel):
role: str = Field(min_length=1, max_length=20)
content: str = Field(min_length=1, max_length=4000)
class DesignerSuggestionRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
history: list[ChatTurn] = Field(default_factory=list, max_length=20)
currentSchema: str | None = Field(default=None, max_length=8000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
class DesignerSuggestionResponse(BaseModel):
suggestion: DesignerSuggestion
model: str
requiresUserConfirmation: bool = True
+98 -1
View File
@@ -5,7 +5,14 @@ from typing import Protocol
import httpx
from app.config import Settings
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveProgressAnswerRequest
from app.models import (
LeaveDraftSuggestion,
LeaveDraftSuggestionRequest,
LeaveProgressAnswerRequest,
DesignerSuggestionRequest,
DesignerSuggestion,
ChatTurn,
)
class SuggestionGateway(Protocol):
@@ -16,6 +23,10 @@ class ProgressGateway(Protocol):
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
class DesignerGateway(Protocol):
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
class QwenSuggestionGateway:
def __init__(self, settings: Settings) -> None:
self.settings = settings
@@ -82,6 +93,42 @@ class QwenSuggestionGateway:
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
user_content = json.dumps(
{
"message": request.message,
"history": [t.model_dump() for t in request.history],
"currentSchema": request.currentSchema,
"timezone": request.timezone,
},
ensure_ascii=False,
)
messages: list[dict[str, str]] = [{"role": "system", "content": DESIGNER_SYSTEM_PROMPT}]
for turn in request.history:
messages.append({"role": turn.role, "content": turn.content})
messages.append({"role": "user", "content": user_content})
payload = {
"model": self.settings.qwen_model,
"temperature": 0.2,
"response_format": {"type": "json_object"},
"messages": messages,
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return DesignerSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid designer response") from exc
SYSTEM_PROMPT = """
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
@@ -97,6 +144,56 @@ PROGRESS_SYSTEM_PROMPT = """
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
""".strip()
DESIGNER_SYSTEM_PROMPT = """
你是企业 OA 表单与流程设计助手,通过多阶段对话帮助用户生成正确的表单和审批流程 Schema。
## 工作流程(5 个阶段)
1. UNDERSTANDING(理解):分析用户需求,复述你的理解,判断信息是否充分。
- 如果信息不足需要澄清 → 进入 CLARIFYING
- 如果信息充分 → 直接进入 GENERATING
2. CLARIFYING(澄清):向用户提出具体问题,每次最多 3 个问题。
- 用户回答后重新评估,信息充分则进入 GENERATING
3. GENERATING(生成):根据理解生成完整的表单字段和审批流程 Schema。
- 生成后自动进入 VALIDATING
4. VALIDATING(校验):自检生成的 Schema 是否正确完整。
- 检查项:字段 key 唯一、必填字段合理、控件类型匹配、审批步骤 ≥ 2、assigneeVariable 不重复(PARALLEL/CONDITIONAL)、select 有 options
- 有问题则修复后重新校验,无问题则进入 CONFIRMING
5. CONFIRMING(确认):展示最终 Schema 摘要,请用户确认或调整。
- 用户确认 → schemaReady = true
- 用户要求调整 → 回到 GENERATING
## 输出格式
输出必须是 JSON 对象,包含以下字段:
- stage:当前阶段(UNDERSTANDING/CLARIFYING/GENERATING/VALIDATING/CONFIRMING
- understanding:你对用户需求的理解复述(中文)
- formTitle:表单中文标题
- formKey:英文 kebab-case 标识符
- fields:数组,每项含 key(英文 camelCase)、label(中文)、controltext/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
- process:对象,含 modeSERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL
- summary:一句话总结
- assumptions:你做出的假设
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
- validationIssues:校验发现的问题数组,每项含 field、issue、severityERROR/WARNING
- schemaReadySchema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true
## 约束
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorIdOA 管理员)、hrReviewerIdHR 复核人)
- 字段 key 用英文 camelCase 且唯一,label 用中文
- 审批流程至少 2 个步骤
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
- CONDITIONAL 模式需设置 conditionThresholdDays
- 不得输出权限、租户、隐藏字段等敏感信息
- 不得使用 Markdown
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
""".strip()
class QwenConfigurationError(RuntimeError):
pass
+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,74 @@
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() }
@GetMapping("/processes") fun processes(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> {
val a=actor(jwt)
return dsl.fetch("SELECT process_definition_key,version,process_definition_id,deployment_id,name,mode,status,template_spec::text template_spec,created_at FROM workflow.process_template WHERE tenant_id=? ORDER BY created_at DESC",a.tenantId).map { record ->
mapOf(
"key" to record.get("process_definition_key",String::class.java),"version" to record.get("version",Int::class.java),
"processDefinitionId" to record.get("process_definition_id",String::class.java),"deploymentId" to record.get("deployment_id",String::class.java),
"name" to record.get("name",String::class.java),"mode" to record.get("mode",String::class.java),"status" to record.get("status",String::class.java),
"template" to mapper.readValue(record.get("template_spec",String::class.java),Map::class.java),"createdAt" to record.get("created_at"),
)
}
}
@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") @Transactional 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","流程部署失败")
dsl.execute("INSERT INTO workflow.process_template(tenant_id,process_definition_key,version,process_definition_id,deployment_id,name,mode,template_spec,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSONB),?)",a.tenantId,definition.key,definition.version,definition.id,deployment.id,body.name,body.mode,mapper.writeValueAsString(body),a.id)
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
}
}
@@ -0,0 +1,43 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiDesignerService
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
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.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/ai/designer-suggestions")
class AiDesignerController(
private val currentUserService: CurrentUserService,
private val service: AiDesignerService,
) {
@PostMapping
fun suggest(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody request: AiDesignerSuggestionRequest,
): DesignerSuggestionResult = service.suggest(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
request.message,
request.history?.map { ChatTurn(it.role, it.content) } ?: emptyList(),
request.currentSchema,
request.timezone,
)
}
data class AiDesignerSuggestionRequest(
@field:NotBlank @field:Size(max = 2000) val message: String,
val history: List<ChatTurnDto>? = null,
@field:Size(max = 8000) val currentSchema: String? = null,
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
)
data class ChatTurnDto(val role: String, val content: String)
@@ -0,0 +1,47 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiDesignerService(
private val gateway: AiDesignerGateway,
private val auditService: AuditService,
) {
fun suggest(actor: CurrentUser, message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult {
actor.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT)
val normalized = message.trim()
if (normalized.isEmpty() || normalized.length > 2000) {
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
}
if (timezone.isBlank() || timezone.length > 64) {
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
}
val result = gateway.suggest(normalized, history, currentSchema?.takeIf { it.isNotBlank() }, timezone)
if (!result.requiresUserConfirmation) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
}
auditService.recordSuccess(
actor,
"AI_DESIGNER_SUGGESTED",
"AI_SUGGESTION",
"designer",
null,
mapOf(
"model" to result.model,
"promptLength" to normalized.length,
"fieldCount" to result.suggestion.fields.size,
"hasProcess" to (result.suggestion.process != null),
),
)
return result
}
}
@@ -0,0 +1,57 @@
package com.all8ai.aioa.ai.domain
data class DesignerFormField(
val key: String,
val label: String,
val control: String,
val required: Boolean = false,
val placeholder: String? = null,
val options: List<String>? = null,
val helperText: String? = null,
)
data class DesignerProcessStep(
val name: String,
val assigneeVariable: String,
)
data class DesignerProcessSuggestion(
val mode: String = "SERIAL",
val steps: List<DesignerProcessStep> = emptyList(),
val conditionThresholdDays: Double? = null,
)
data class SchemaValidationIssue(
val field: String,
val issue: String,
val severity: String = "WARNING",
)
data class DesignerSuggestion(
val stage: String = "UNDERSTANDING",
val formTitle: String? = null,
val formKey: String? = null,
val fields: List<DesignerFormField> = emptyList(),
val process: DesignerProcessSuggestion? = null,
val summary: String = "",
val understanding: String = "",
val assumptions: List<String> = emptyList(),
val needsClarification: List<String> = emptyList(),
val validationIssues: List<SchemaValidationIssue> = emptyList(),
val schemaReady: Boolean = false,
)
data class DesignerSuggestionResult(
val suggestion: DesignerSuggestion,
val model: String,
val requiresUserConfirmation: Boolean = true,
)
data class ChatTurn(
val role: String,
val content: String,
)
fun interface AiDesignerGateway {
fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult
}
@@ -0,0 +1,31 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiDesignerGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiDesignerGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult = try {
client.post()
.uri("/v1/designer/suggest")
.body(DesignerRequest(message, history, currentSchema, timezone))
.retrieve()
.body(DesignerSuggestionResult::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回设计建议")
} catch (e: ApiException) {
throw e
} catch (_: RestClientException) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
}
}
private data class DesignerRequest(val message: String, val history: List<ChatTurn>, val currentSchema: String?, val timezone: String)
@@ -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,18 @@
CREATE TABLE workflow.process_template (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
process_definition_key VARCHAR(64) NOT NULL,
version INTEGER NOT NULL CHECK (version > 0),
process_definition_id VARCHAR(255) NOT NULL,
deployment_id VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
mode VARCHAR(20) NOT NULL CHECK (mode IN ('SERIAL', 'PARALLEL', 'CONDITIONAL')),
template_spec JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DEPLOYED' CHECK (status IN ('DEPLOYED', 'SUSPENDED')),
created_by UUID NOT NULL REFERENCES identity.user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, process_definition_key, version),
UNIQUE (process_definition_id)
);
CREATE INDEX idx_process_template_tenant_created
ON workflow.process_template (tenant_id, created_at DESC);
@@ -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,
)
+1
View File
@@ -2,3 +2,4 @@ org.gradle.configuration-cache=true
org.gradle.caching=true
org.gradle.parallel=true
kotlin.code.style=official
org.gradle.java.installations.paths=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
+48 -3
View File
@@ -1,12 +1,57 @@
openapi: 3.1.0
info:
title: AIOA API
version: 0.12.0
version: 0.17.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/processes:
get: { operationId: listProcessTemplates, summary: 查询可重新编辑的可视化流程部署历史, responses: { "200": { description: 流程模板版本列表 } } }
/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 +676,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]
+1
View File
@@ -38,6 +38,7 @@ services:
- "8081:8080"
volumes:
- ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro
- ./keycloak/themes/aioa:/opt/keycloak/themes/aioa:ro
minio:
image: minio/minio:RELEASE.2025-06-13T11-33-47Z
+32 -1
View File
@@ -1,10 +1,14 @@
{
"realm": "aioa",
"enabled": true,
"displayName": "AIOA Development",
"displayName": "AIOA 智能办公自动化平台",
"registrationAllowed": false,
"resetPasswordAllowed": true,
"loginWithEmailAllowed": true,
"loginTheme": "aioa",
"internationalizationEnabled": true,
"defaultLocale": "zh_CN",
"supportedLocales": ["zh-CN", "zh_CN", "en", "en-US"],
"roles": {
"realm": [
{ "name": "employee", "description": "普通员工" },
@@ -42,6 +46,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": [
@@ -0,0 +1,105 @@
# AIOA 自定义中文消息
# 登录页
loginTitle=AIOA 管理平台登录
loginTitleHtml=AIOA 管理平台
loginSubtitle=请使用您的账号登录
doLogIn=登 录
doRegister=注册
doCancel=取消
doSubmit=提交
doBack=返回
doYes=
doNo=
doContinue=继续
doIgnore=忽略
doAccept=接受
doDecline=拒绝
doForgotPassword=忘记密码?
doClickHere=点击这里
doTryAgain=重试
doTryAnotherWay=尝试其他方式
doConfirmDelete=确认删除
# 账号字段
username=用户名
usernameOrEmail=用户名或邮箱
password=密码
passwordConfirm=确认密码
passwordNew=新密码
passwordConfirmNew=再次输入新密码
email=邮箱
firstName=
lastName=
rememberMe=记住我
# 提示信息
loginTotpTitle=移动认证器应用
loginTotpMessage=请输入验证码
loginTotpStep1=第 1 步:在认证器应用中添加此账号
loginTotpStep2=第 2 步:输入验证码
loginTotpStep3DeviceName=请为您的设备命名(可选)
loginTotpManualStep2=请输入以下密钥:
loginTotpScanBarcode=扫描二维码
loginTotpOneTimeCode=验证码
loginTotp.totp=基于时间(TOTP
loginTotp.hotp=基于计数器(HOTP
# 忘记密码
emailForgotTitle=忘记密码
emailForgotInstructions=请输入您的用户名或邮箱,我们将发送重置密码的链接。
# 错误信息
invalidUserMessage=用户名或密码错误
invalidPasswordMessage=密码错误
invalidEmailMessage=请输入有效的邮箱地址
invalidUsernameMessage=用户名不存在
invalidUsernameOrEmailMessage=用户名或邮箱不存在
accountDisabledMessage=账号已被禁用
accountTemporarilyDisabledMessage=账号已被临时锁定
expiredCodeMessage=登录超时,请重试
expiredActionMessage=操作超时,请重试
sessionLimitExceeded=会话数超限
identityProviderDisabledMessage=身份提供者已被禁用
identityProviderAlreadyLinkedMessage=该第三方账号已绑定其他用户
identityProviderNotFoundMessage=无法连接到身份提供者
emailNotFoundMessage=未找到与该邮箱关联的账号
federatedIdentityLinkMessage=请验证您的邮箱以关联账号
federatedIdentityUnlinkSuccessMsg=已成功解除第三方账号关联
confirmAccountLinkingMsg=确认关联您的账号 {0} 与 {1}
confirmEmailAddress=请确认您的邮箱地址
confirmExecutionDescription=确认执行操作
# 信息提示
emailSendInstructions=重置密码邮件已发送
emailSendAlreadyInstructions=重置密码邮件已发送
updatePasswordTitle=更新密码
updatePasswordMessage=请设置您的新密码
updatePasswordMessageTwo=请确认您的新密码
# 页脚
termsText=登录即表示您同意我们的服务条款
privacyPolicy=隐私政策
termsOfService=服务条款
# 其他
requiredMessage=此字段为必填
browserLogin=浏览器登录
otherMethods=其他登录方式
grantInformation=请确认授权以下权限
scopes=权限范围
# OTP 验证
loginTotpIntro=为了您的账号安全,请配置两步验证
# 信息页面
infoTitle=提示
infoMessage=请按照说明操作
# 登出
logoutTitle=您已登出
logoutSignOutIn=您已成功登出,可以重新登录
backToApplication=返回应用
# 会话超时
sessionTimeoutMsg=您的会话已超时,请重新登录
@@ -0,0 +1,124 @@
/*
* AIOA 自定义登录主题样式
* 适配 Keycloak v2 主题 (PatternFly v5)
*/
:root {
--pf-v5-global--primary-color--100: #2563eb;
--pf-v5-global--primary-color--200: #1d4ed8;
--pf-v5-global--BackgroundColor--100: #f0f4f8;
--aioa-gradient-start: #1e3a8a;
--aioa-gradient-end: #2563eb;
}
/* 整体页面背景 */
.login-pf body,
body.pf-v5-c-login {
background: linear-gradient(135deg, var(--aioa-gradient-start) 0%, var(--aioa-gradient-end) 100%) !important;
min-height: 100vh;
}
/* 登录卡片 */
.pf-v5-c-login__main {
background: #ffffff;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.15);
}
.pf-v5-c-login__main-header {
padding: 40px 36px 0 36px;
}
.pf-v5-c-login__main-header h1 {
font-size: 22px;
font-weight: 700;
color: #1e293b;
}
.pf-v5-c-login__main-header p {
font-size: 14px;
color: #64748b;
}
.pf-v5-c-login__main-body {
padding: 28px 36px;
}
/* 表单输入框 */
.pf-v5-c-form__group {
margin-bottom: 20px;
}
.pf-v5-c-form-control {
border: 1.5px solid #cbd5e1 !important;
border-radius: 8px !important;
padding: 10px 14px !important;
font-size: 15px !important;
transition: border-color 0.2s, box-shadow 0.2s;
}
.pf-v5-c-form-control:focus {
border-color: #2563eb !important;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.15) !important;
outline: none;
}
/* 登录按钮 */
.pf-v5-c-button.pf-m-primary {
background: linear-gradient(135deg, #1e3a8a, #2563eb) !important;
border: none !important;
border-radius: 8px !important;
padding: 12px 24px !important;
font-size: 15px !important;
font-weight: 600 !important;
width: 100%;
cursor: pointer;
transition: opacity 0.2s, transform 0.1s;
}
.pf-v5-c-button.pf-m-primary:hover {
opacity: 0.92;
}
.pf-v5-c-button.pf-m-primary:active {
transform: scale(0.98);
}
/* 次要链接 */
.pf-v5-c-login__main-footer a,
.pf-v5-c-form__helper-text a {
color: #2563eb;
font-size: 14px;
text-decoration: none;
}
.pf-v5-c-login__main-footer a:hover,
.pf-v5-c-form__helper-text a:hover {
text-decoration: underline;
}
/* 提示信息 */
.pf-v5-c-alert {
border-radius: 8px;
font-size: 14px;
}
/* 页脚 */
.pf-v5-c-login__footer {
text-align: center;
font-size: 12px;
color: rgba(255, 255, 255, 0.7);
padding: 16px;
}
/* 响应式 */
@media (max-width: 480px) {
.pf-v5-c-login__main {
border-radius: 12px;
}
.pf-v5-c-login__main-header,
.pf-v5-c-login__main-body {
padding-left: 20px;
padding-right: 20px;
}
}
@@ -0,0 +1,18 @@
// AIOA 开发环境:预填充测试管理员凭据
(function () {
function prefill() {
var usernameInput = document.getElementById('username');
var passwordInput = document.getElementById('password');
if (usernameInput && !usernameInput.value) {
usernameInput.value = 'admin';
}
if (passwordInput && !passwordInput.value) {
passwordInput.value = 'Admin123!';
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', prefill);
} else {
prefill();
}
})();
@@ -0,0 +1,12 @@
parent=keycloak.v2
import=common/keycloak
# Styles generated by the server
styles=css/login.css
scripts=js/prefill.js
# Meta tags
meta=viewport==width=device-width,initial-scale=1.0
# Branding
displayName=AIOA 登录
+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。
- 创建、发布、启停均写入审计。
+14
View File
@@ -48,6 +48,20 @@
- [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 部署
- [x] 可视化流程设计源数据持久化、部署历史和版本重新加载
## Definition of Done
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。
+15
View File
@@ -51,3 +51,18 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
```
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
## 可视化流程模板
Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowable BPMN 模板:
- 串行审批:审批节点依次执行,任一节点驳回即终止。
- 并行会签:多个审批节点同时执行,全部通过后汇聚,任一驳回即终止其他分支。
- 条件审批:首个审批通过后按请假时长阈值决定直接结束或增加复核节点。
流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。
审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin``hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。
自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。
每次从设计器部署时,平台同时保存租户、流程 Key、Flowable 版本、部署标识和受约束模板 JSON。管理员可从部署历史重新加载任意版本,修改后以相同 Key 部署为新版本;运行中实例仍引用原有 `processDefinitionId`
@@ -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.'