Compare commits
10 Commits
090a7e33ce
...
34a279721e
| Author | SHA1 | Date | |
|---|---|---|---|
| 34a279721e | |||
| cd72ec3756 | |||
| 9c366cc872 | |||
| 89c44a61a7 | |||
| 342a1fcb33 | |||
| 15abd8a32f | |||
| 24101398d8 | |||
| e19bdb8728 | |||
| b311be6aa6 | |||
| d0a2f4f923 |
@@ -34,3 +34,8 @@ firebase-service-account*.json
|
|||||||
.local/
|
.local/
|
||||||
coverage/
|
coverage/
|
||||||
reports/
|
reports/
|
||||||
|
node_modules/
|
||||||
|
admin-web/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
admin-web/vite.config.js
|
||||||
|
admin-web/vite.config.d.ts
|
||||||
|
|||||||
@@ -92,6 +92,26 @@ flutter-verify:
|
|||||||
paths:
|
paths:
|
||||||
- mobile/reports/flutter-test.json
|
- 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:
|
android-debug-build:
|
||||||
stage: build
|
stage: build
|
||||||
image: ghcr.io/cirruslabs/flutter:stable
|
image: ghcr.io/cirruslabs/flutter:stable
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实
|
|||||||
```text
|
```text
|
||||||
backend/ Kotlin + Spring Boot 模块化单体
|
backend/ Kotlin + Spring Boot 模块化单体
|
||||||
mobile/ Flutter 移动客户端
|
mobile/ Flutter 移动客户端
|
||||||
|
admin-web/ React + TypeScript 表单设计与流程配置管理端
|
||||||
ai-service/ Python AI 服务
|
ai-service/ Python AI 服务
|
||||||
contracts/ OpenAPI 与事件契约
|
contracts/ OpenAPI 与事件契约
|
||||||
deploy/ 本地与部署配置
|
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)。
|
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` 默认执行:
|
仓库根目录的 `.gitlab-ci.yml` 默认执行:
|
||||||
@@ -78,6 +81,7 @@ Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engine
|
|||||||
- JDK 21 后端测试,并上传 JUnit 报告;
|
- JDK 21 后端测试,并上传 JUnit 报告;
|
||||||
- Python 3.12 AI 服务编译检查与测试;
|
- Python 3.12 AI 服务编译检查与测试;
|
||||||
- Flutter 格式检查、静态分析和测试;
|
- Flutter 格式检查、静态分析和测试;
|
||||||
|
- React 管理端单元测试和生产构建;
|
||||||
- Android Debug APK 构建与产物归档。
|
- Android Debug APK 构建与产物归档。
|
||||||
|
|
||||||
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
|
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
|
||||||
|
|||||||
@@ -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 容器或通过管理控制台补入同名客户端。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||||
Generated
+2959
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
import { userManager } from './auth';
|
||||||
|
import { buildSchemas, type ControlType, type Field } from './schema';
|
||||||
|
import { ProcessBindings } from './ProcessBindings';
|
||||||
|
import { ProcessDesigner } from './ProcessDesigner';
|
||||||
|
|
||||||
|
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
|
||||||
|
const palette: { control: ControlType; label: string }[] = [
|
||||||
|
{ control: 'text', label: '单行文本' }, { control: 'textArea', label: '多行文本' },
|
||||||
|
{ control: 'select', label: '下拉选择' }, { control: 'dateTime', label: '日期时间' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function App() {
|
||||||
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
useEffect(() => {
|
||||||
|
(async () => {
|
||||||
|
if (location.pathname === '/callback') { await userManager.signinRedirectCallback(); history.replaceState({}, '', '/'); }
|
||||||
|
const user = await userManager.getUser(); setAuthenticated(Boolean(user && !user.expired)); setLoading(false);
|
||||||
|
})().catch(() => setLoading(false));
|
||||||
|
}, []);
|
||||||
|
if (loading) return <div className="center">正在恢复管理会话…</div>;
|
||||||
|
if (!authenticated) return <Login />;
|
||||||
|
return <Workspace onLogout={() => userManager.signoutRedirect()} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Login() {
|
||||||
|
return <main className="login"><div className="brandMark">A</div><h1>AIOA 管理设计中心</h1><p>设计表单、发布版本并绑定 Flowable 流程</p><button onClick={() => userManager.signinRedirect()}>使用 Keycloak 登录</button></main>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Workspace({ onLogout }: { onLogout: () => void }) {
|
||||||
|
const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
|
||||||
|
return <div className="workspace"><header><div><strong>AIOA</strong><span>管理设计中心</span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}>表单设计</button><button className={view === 'processes' ? 'navActive' : 'navButton'} onClick={() => setView('processes')}>流程设计</button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}>流程绑定</button></nav><button className="ghost" onClick={onLogout}>退出</button></header>{view === 'forms' ? <Designer /> : view === 'processes' ? <ProcessDesigner /> : <ProcessBindings />}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Designer() {
|
||||||
|
const [formKey, setFormKey] = useState('leave-request');
|
||||||
|
const [title, setTitle] = useState('请假申请');
|
||||||
|
const [fields, setFields] = useState<Field[]>([
|
||||||
|
{ id: crypto.randomUUID(), key: 'type', label: '请假类型', control: 'select', required: true, options: ['PERSONAL', 'SICK', 'ANNUAL'] },
|
||||||
|
{ id: crypto.randomUUID(), key: 'startsAt', label: '开始时间', control: 'dateTime', required: true },
|
||||||
|
{ id: crypto.randomUUID(), key: 'endsAt', label: '结束时间', control: 'dateTime', required: true },
|
||||||
|
{ id: crypto.randomUUID(), key: 'reason', label: '请假原因', control: 'textArea', required: true },
|
||||||
|
]);
|
||||||
|
const [selected, setSelected] = useState(fields[0].id);
|
||||||
|
const [versions, setVersions] = useState<FormVersion[]>([]);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
|
||||||
|
const selectedField = fields.find(f => f.id === selected);
|
||||||
|
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
|
||||||
|
useEffect(() => { void loadVersions(); }, []);
|
||||||
|
|
||||||
|
function add(control: ControlType) {
|
||||||
|
const index = fields.length + 1;
|
||||||
|
const field: Field = { id: crypto.randomUUID(), key: `field${index}`, label: `字段 ${index}`, control, required: false, ...(control === 'select' ? { options: ['OPTION_1', 'OPTION_2'] } : {}) };
|
||||||
|
setFields([...fields, field]); setSelected(field.id);
|
||||||
|
}
|
||||||
|
function update(patch: Partial<Field>) { setFields(fields.map(f => f.id === selected ? { ...f, ...patch } : f)); }
|
||||||
|
function move(id: string, delta: number) { const from = fields.findIndex(f => f.id === id), to = from + delta; if (to < 0 || to >= fields.length) return; const next = [...fields]; [next[from], next[to]] = [next[to], next[from]]; setFields(next); }
|
||||||
|
function dropField(event: React.DragEvent, targetId: string) { event.preventDefault(); const sourceId = event.dataTransfer.getData('field'); const source = fields.findIndex(f => f.id === sourceId), target = fields.findIndex(f => f.id === targetId); if (source < 0 || target < 0) return; const next = [...fields]; const [item] = next.splice(source, 1); next.splice(target, 0, item); setFields(next); }
|
||||||
|
async function save() {
|
||||||
|
try { const result = await api<{ version: number }>('/admin/process-configuration/forms', { method: 'POST', body: JSON.stringify({ formKey, ...schemas }) }); setMessage(`草稿 v${result.version} 已保存`); await loadVersions(); }
|
||||||
|
catch (e) { setMessage(e instanceof Error ? e.message : '保存失败'); }
|
||||||
|
}
|
||||||
|
async function publish(version: number) {
|
||||||
|
try { await api(`/admin/process-configuration/forms/${formKey}/${version}/publish`, { method: 'POST' }); setMessage(`v${version} 已发布`); await loadVersions(); }
|
||||||
|
catch (e) { setMessage(e instanceof Error ? e.message : '发布失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="appShell">
|
||||||
|
<div className="subHeader"><div><strong>可视化表单设计器</strong><span>Schema 驱动移动端卡片</span></div><button onClick={save}>保存草稿</button></div>
|
||||||
|
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
|
||||||
|
<section className="meta"><label>表单 Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label>表单标题<input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
|
||||||
|
<main className="designer">
|
||||||
|
<aside><h2>控件</h2>{palette.map(p => <button className="palette" key={p.control} onClick={() => add(p.control)}>+ {p.label}</button>)}<h2>版本</h2>{versions.filter(v => v.form_key === formKey).map(v => <div className="version" key={v.version}><span>v{v.version} · {v.status}</span>{v.status === 'DRAFT' && <button onClick={() => publish(v.version)}>发布</button>}</div>)}</aside>
|
||||||
|
<section className="canvas"><div className="phone"><div className="phoneHeader">{title}</div>{fields.map((f, i) => <div className={`field ${selected === f.id ? 'selected' : ''}`} key={f.id} draggable onDragStart={e => e.dataTransfer.setData('field', f.id)} onDragOver={e => e.preventDefault()} onDrop={e => dropField(e, f.id)} onClick={() => setSelected(f.id)}><label>{f.label}{f.required && ' *'}</label>{f.control === 'textArea' ? <textarea disabled placeholder={f.placeholder} /> : f.control === 'select' ? <select disabled><option>{f.options?.[0] ?? '请选择'}</option></select> : <input disabled placeholder={f.control === 'dateTime' ? '请选择日期和时间' : f.placeholder} />}<div className="fieldActions"><button onClick={e => { e.stopPropagation(); move(f.id, -1); }}>↑</button><button onClick={e => { e.stopPropagation(); move(f.id, 1); }}>↓</button><button onClick={e => { e.stopPropagation(); setFields(fields.filter(x => x.id !== f.id)); }}>×</button></div><small>{i + 1}</small></div>)}</div></section>
|
||||||
|
<aside className="properties"><h2>字段属性</h2>{selectedField ? <><label>字段 Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label>显示名称<input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label>占位提示<input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} />必填</label>{selectedField.control === 'select' && <label>选项(每行一个)<textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p>请选择字段</p>}<details><summary>生成的 Schema</summary><pre>{JSON.stringify(schemas, null, 2)}</pre></details></aside>
|
||||||
|
</main>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
|
||||||
|
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
|
||||||
|
type Step = { id: string; name: string; assigneeVariable: Assignee };
|
||||||
|
type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string };
|
||||||
|
const fallbackRules: ApprovalRule[] = [
|
||||||
|
{ variable: 'approverId', label: '发起人所在部门主管', sourceType: 'POSITION', selector: 'manager', scope: 'APPLICANT_DEPARTMENT', emptyPolicy: 'REJECT_SUBMISSION', description: '根据发起人的主任职解析部门主管。' },
|
||||||
|
{ variable: 'oaAdministratorId', label: '租户 OA 管理员', sourceType: 'ROLE', selector: 'oa_admin', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 OA 管理员。' },
|
||||||
|
{ variable: 'hrReviewerId', label: '租户 HR 复核人', sourceType: 'ROLE', selector: 'hr_reviewer', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 HR 复核人。' },
|
||||||
|
];
|
||||||
|
const modes: { value: Mode; label: string; hint: string }[] = [
|
||||||
|
{ value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' },
|
||||||
|
{ value: 'PARALLEL', label: '并行会签', hint: '同时审批,全部通过才结束' },
|
||||||
|
{ value: 'CONDITIONAL', label: '条件审批', hint: '达到时长阈值后增加复核' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ProcessDesigner() {
|
||||||
|
const [key, setKey] = useState('leaveApprovalCustom');
|
||||||
|
const [name, setName] = useState('自定义请假审批');
|
||||||
|
const [mode, setMode] = useState<Mode>('SERIAL');
|
||||||
|
const [thresholdDays, setThresholdDays] = useState(3);
|
||||||
|
const [steps, setSteps] = useState<Step[]>([
|
||||||
|
{ id: crypto.randomUUID(), name: '部门主管审批', assigneeVariable: 'approverId' },
|
||||||
|
{ id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' },
|
||||||
|
]);
|
||||||
|
const [selected, setSelected] = useState(steps[0].id);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
|
||||||
|
useEffect(() => { api<ApprovalRule[]>('/admin/process-configuration/approver-rules').then(setRules).catch(error => setMessage(error instanceof Error ? error.message : '审批规则加载失败')); }, []);
|
||||||
|
const selectedStep = steps.find(step => step.id === selected);
|
||||||
|
const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps;
|
||||||
|
const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]);
|
||||||
|
const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
|
||||||
|
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
|
||||||
|
|
||||||
|
function changeMode(next: Mode) {
|
||||||
|
setMode(next);
|
||||||
|
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
|
||||||
|
}
|
||||||
|
function update(patch: Partial<Step>) { setSteps(steps.map(step => step.id === selected ? { ...step, ...patch } : step)); }
|
||||||
|
function addStep() {
|
||||||
|
const step: Step = { id: crypto.randomUUID(), name: `审批节点 ${steps.length + 1}`, assigneeVariable: 'approverId' };
|
||||||
|
setSteps([...steps, step]); setSelected(step.id);
|
||||||
|
}
|
||||||
|
function move(id: string, delta: number) {
|
||||||
|
const from = steps.findIndex(step => step.id === id), to = from + delta;
|
||||||
|
if (to < 0 || to >= steps.length) return;
|
||||||
|
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
|
||||||
|
}
|
||||||
|
async function deploy() {
|
||||||
|
try {
|
||||||
|
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
|
||||||
|
key, name, mode, steps: effectiveSteps.map(({ name: stepName, assigneeVariable }) => ({ name: stepName, assigneeVariable })),
|
||||||
|
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
|
||||||
|
}) });
|
||||||
|
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
|
||||||
|
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="processDesignerPage">
|
||||||
|
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
|
||||||
|
<div className="processTop"><div><h1>可视化流程设计器</h1><p>使用安全模板设计串行、并行和条件审批,并直接发布到 Flowable。</p></div><button onClick={deploy} disabled={duplicateRestrictedRule}>校验并部署</button></div>
|
||||||
|
<section className="processMeta"><label>流程 Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label>流程名称<input value={name} onChange={e => setName(e.target.value)} /></label></section>
|
||||||
|
<div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div>
|
||||||
|
<div className="processWorkspace">
|
||||||
|
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
|
||||||
|
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
|
||||||
|
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
|
||||||
|
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{rules.find(item => item.variable === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}天` : undefined} />}</div>)}</div>
|
||||||
|
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
|
||||||
|
</div>
|
||||||
|
<aside className="processProperties"><h2>流程属性</h2>{mode === 'CONDITIONAL' && <label>时长阈值(天)<input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2>审批节点</h2>{selectedStep ? <><label>节点名称<input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label>审批人规则<select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span>来源</span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span>范围</span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small>无人匹配时:拒绝提交,不自动跳过</small></div>}<div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}>上移</button><button onClick={() => move(selectedStep.id, 1)}>下移</button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}>删除</button></div></> : <p>请选择审批节点</p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}>+ 添加审批节点</button>}{duplicateRestrictedRule && <div className="validationError">并行会签或条件复核必须选择不同审批人规则,以满足职责分离。</div>}<div className="securityNote"><strong>安全约束</strong><p>审批规则由后端提供并在流程启动时根据有效组织、岗位和角色解析;无人匹配或职责冲突时拒绝提交。</p></div></aside>
|
||||||
|
</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return <div className={`templateNode ${kind}`}><strong>{title}</strong><small>{subtitle}</small></div>; }
|
||||||
|
function Arrow({ label }: { label?: string }) { return <div className="graphArrow">{label && <span>{label}</span>}→</div>; }
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
|
||||||
|
|
||||||
|
const issuer = import.meta.env.VITE_OIDC_ISSUER ?? 'http://localhost:8081/realms/aioa';
|
||||||
|
export const userManager = new UserManager({
|
||||||
|
authority: issuer,
|
||||||
|
client_id: 'aioa-admin-web',
|
||||||
|
redirect_uri: `${window.location.origin}/callback`,
|
||||||
|
post_logout_redirect_uri: window.location.origin,
|
||||||
|
response_type: 'code',
|
||||||
|
scope: 'openid profile email',
|
||||||
|
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function accessToken(): Promise<string | null> {
|
||||||
|
const user = await userManager.getUser();
|
||||||
|
return user && !user.expired ? user.access_token : null;
|
||||||
|
}
|
||||||
@@ -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>);
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime';
|
||||||
|
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[] };
|
||||||
|
|
||||||
|
export function buildSchemas(title: string, fields: Field[]) {
|
||||||
|
const properties: Record<string, unknown> = {};
|
||||||
|
for (const field of fields) {
|
||||||
|
properties[field.key] = field.control === 'select'
|
||||||
|
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
|
||||||
|
: field.control === 'dateTime'
|
||||||
|
? { type: 'string', format: 'date-time' }
|
||||||
|
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
|
||||||
|
uiSchema: { description: `${title} · 由 AIOA 表单设计器生成`, sections: [{ title: '基本信息', controls: fields.map(f => ({ field: f.key, label: f.label, control: f.control, placeholder: f.placeholder, ...(f.control === 'select' ? { optionLabels: Object.fromEntries((f.options ?? []).map(v => [v, v])) } : {}) })) }] },
|
||||||
|
};
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -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 } });
|
||||||
@@ -35,6 +35,7 @@ dependencies {
|
|||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
|
testRuntimeOnly("com.h2database:h2")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
@@ -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 角色且账号状态正常的人员。",
|
||||||
|
),
|
||||||
|
)
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
|
import org.flowable.engine.RepositoryService
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/process-configuration")
|
||||||
|
class ProcessConfigurationController(
|
||||||
|
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
|
||||||
|
private val flowable:RepositoryService,private val audit:AuditService,
|
||||||
|
) {
|
||||||
|
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
|
||||||
|
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
|
||||||
|
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
|
||||||
|
@GetMapping("/approver-rules") fun approverRules(@AuthenticationPrincipal jwt:Jwt):List<ApprovalRuleOption> { actor(jwt);return approvalRuleCatalog() }
|
||||||
|
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
|
||||||
|
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
|
||||||
|
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
|
||||||
|
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
|
||||||
|
}
|
||||||
|
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
|
||||||
|
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
|
||||||
|
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
|
||||||
|
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
|
||||||
|
}
|
||||||
|
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
|
||||||
|
val a=actor(jwt)
|
||||||
|
if(!isValidDurationRange(body.minDurationMinutes,body.maxDurationMinutes)) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_BINDING_RANGE_INVALID","时长范围无效")
|
||||||
|
val publishedFormExists=dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM form.definition WHERE tenant_id=? AND form_key=? AND version=? AND status='PUBLISHED') found",a.tenantId,body.formKey,body.formVersion)?.get("found",Boolean::class.java)==true
|
||||||
|
if(!publishedFormExists) throw ApiException(HttpStatus.BAD_REQUEST,"PUBLISHED_FORM_NOT_FOUND","已发布表单版本不存在")
|
||||||
|
if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
|
||||||
|
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
|
||||||
|
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
|
||||||
|
}
|
||||||
|
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
|
||||||
|
@PostMapping("/processes/deploy") fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
|
||||||
|
val a=actor(jwt);val xml=generateProcessTemplate(body)
|
||||||
|
val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
|
||||||
|
val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
|
||||||
|
?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
|
||||||
|
audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
|
||||||
|
return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
|
||||||
|
}
|
||||||
|
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
|
||||||
|
}
|
||||||
|
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
|
||||||
|
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
|
||||||
|
data class BindingStatusCommand(val status:String)
|
||||||
|
internal fun isValidDurationRange(min:Long?,max:Long?):Boolean = min?.let { it>=0 } != false && max?.let { it>=0 } != false && (min==null || max==null || min<=max)
|
||||||
+83
@@ -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("&","&").replace("<","<").replace(">",">").replace("\"",""")
|
||||||
|
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
|
||||||
+35
@@ -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", "输入值无效")
|
||||||
|
}
|
||||||
+24
@@ -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)
|
||||||
+36
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-1
@@ -25,6 +25,8 @@ import java.time.Duration
|
|||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import com.all8ai.aioa.shared.security.ToolPermission
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
import com.all8ai.aioa.shared.security.requirePermission
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||||
|
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class LeaveRequestService(
|
class LeaveRequestService(
|
||||||
@@ -33,6 +35,7 @@ class LeaveRequestService(
|
|||||||
private val routingRepository: ApprovalRoutingRepository,
|
private val routingRepository: ApprovalRoutingRepository,
|
||||||
private val workflowGateway: LeaveWorkflowGateway,
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
private val notificationService: NotificationService? = null,
|
private val notificationService: NotificationService? = null,
|
||||||
|
private val processBindingRouter: ProcessBindingRouter? = null,
|
||||||
) {
|
) {
|
||||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
@@ -86,6 +89,13 @@ class LeaveRequestService(
|
|||||||
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
val existing = getOwn(actor, id)
|
val existing = getOwn(actor, id)
|
||||||
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
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)
|
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||||
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||||
if (approverId == actor.id) {
|
if (approverId == actor.id) {
|
||||||
@@ -121,7 +131,8 @@ class LeaveRequestService(
|
|||||||
)
|
)
|
||||||
if (outcome.replayed) return outcome.leaveRequest
|
if (outcome.replayed) return outcome.leaveRequest
|
||||||
|
|
||||||
val process = workflowGateway.startLeaveApproval(
|
val process = workflowGateway.startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey,
|
||||||
actor.tenantId,
|
actor.tenantId,
|
||||||
outcome.leaveRequest.id,
|
outcome.leaveRequest.id,
|
||||||
actor.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
|
package com.all8ai.aioa.audit.domain
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
data class AuditEvent(
|
data class AuditEvent(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
@@ -18,3 +19,26 @@ data class AuditEvent(
|
|||||||
fun interface AuditEventRepository {
|
fun interface AuditEventRepository {
|
||||||
fun append(event: AuditEvent)
|
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>
|
||||||
|
}
|
||||||
|
|||||||
+30
-1
@@ -2,15 +2,20 @@ package com.all8ai.aioa.audit.infrastructure
|
|||||||
|
|
||||||
import com.all8ai.aioa.audit.domain.AuditEvent
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
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 com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jooq.impl.DSL
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class JooqAuditEventRepository(
|
class JooqAuditEventRepository(
|
||||||
private val dsl: DSLContext,
|
private val dsl: DSLContext,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
) : AuditEventRepository {
|
) : AuditEventRepository, AuditQueryRepository {
|
||||||
override fun append(event: AuditEvent) {
|
override fun append(event: AuditEvent) {
|
||||||
dsl.execute(
|
dsl.execute(
|
||||||
"""
|
"""
|
||||||
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
|
|||||||
objectMapper.writeValueAsString(event.details),
|
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?>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-59
@@ -1,76 +1,64 @@
|
|||||||
package com.all8ai.aioa.forms.api
|
package com.all8ai.aioa.forms.api
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
import com.all8ai.aioa.shared.web.ApiException
|
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.http.HttpStatus
|
||||||
import org.springframework.web.bind.annotation.GetMapping
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
import org.springframework.web.bind.annotation.PathVariable
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
import org.springframework.web.bind.annotation.RequestMapping
|
import org.springframework.web.bind.annotation.*
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import java.util.UUID
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/v1/form-definitions")
|
@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}")
|
@GetMapping("/{formKey}")
|
||||||
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
|
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
|
||||||
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
|
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||||
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
|
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 {
|
companion object {
|
||||||
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
|
||||||
|
|
||||||
val leaveRequestDefinition = FormDefinitionResponse(
|
val leaveRequestDefinition = FormDefinitionResponse(
|
||||||
key = LEAVE_REQUEST_FORM_KEY,
|
LEAVE_REQUEST_FORM_KEY, 1,
|
||||||
version = 1,
|
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
|
||||||
dataSchema = mapOf(
|
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
||||||
"\$id" to "leave-request-v1",
|
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
|
||||||
"title" to "请假申请",
|
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
|
||||||
"type" to "object",
|
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
|
||||||
"required" to listOf("type", "startsAt", "endsAt", "reason"),
|
mapOf("title" to "请假信息", "controls" to listOf(
|
||||||
"properties" to mapOf(
|
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
|
||||||
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
|
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
|
||||||
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
|
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
|
||||||
"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 可以帮助整理表达,但提交前必须由你确认。",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
data class FormDefinitionResponse(
|
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
|
||||||
val key: String,
|
|
||||||
val version: Int,
|
|
||||||
val dataSchema: Map<String, Any>,
|
|
||||||
val uiSchema: Map<String, Any>,
|
|
||||||
)
|
|
||||||
|
|||||||
+14
-1
@@ -13,9 +13,14 @@ enum class ToolPermission {
|
|||||||
AI_LEAVE_PROGRESS_READ_OWN,
|
AI_LEAVE_PROGRESS_READ_OWN,
|
||||||
APPROVAL_TASK_READ_ASSIGNED,
|
APPROVAL_TASK_READ_ASSIGNED,
|
||||||
APPROVAL_TASK_DECIDE_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(
|
data class UserCapabilities(
|
||||||
val permissions: Set<ToolPermission>,
|
val permissions: Set<ToolPermission>,
|
||||||
@@ -41,12 +46,20 @@ object AuthorizationPolicy {
|
|||||||
val permissions = buildSet {
|
val permissions = buildSet {
|
||||||
if ("employee" in user.roles) addAll(employeePermissions)
|
if ("employee" in user.roles) addAll(employeePermissions)
|
||||||
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
|
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(
|
return UserCapabilities(
|
||||||
permissions,
|
permissions,
|
||||||
buildSet {
|
buildSet {
|
||||||
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
|
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("_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.Customizer.withDefaults
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
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
|
@Configuration
|
||||||
class SecurityConfiguration {
|
class SecurityConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
|
||||||
.csrf { it.disable() }
|
.csrf { it.disable() }
|
||||||
|
.cors(withDefaults())
|
||||||
.authorizeHttpRequests {
|
.authorizeHttpRequests {
|
||||||
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
.oauth2ResourceServer { it.jwt(withDefaults()) }
|
.oauth2ResourceServer { it.jwt(withDefaults()) }
|
||||||
.build()
|
.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,
|
leaveType: String,
|
||||||
): StartedProcess
|
): 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 listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||||
|
|
||||||
fun resolveTask(taskId: String): 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?
|
||||||
|
}
|
||||||
+13
-1
@@ -27,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
hrReviewerId: UUID,
|
hrReviewerId: UUID,
|
||||||
durationMinutes: Long,
|
durationMinutes: Long,
|
||||||
leaveType: String,
|
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 {
|
): StartedProcess {
|
||||||
val process = runtimeService.createProcessInstanceBuilder()
|
val process = runtimeService.createProcessInstanceBuilder()
|
||||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
.processDefinitionKey(processDefinitionKey)
|
||||||
.businessKey(leaveRequestId.toString())
|
.businessKey(leaveRequestId.toString())
|
||||||
.variables(
|
.variables(
|
||||||
mapOf(
|
mapOf(
|
||||||
|
|||||||
+31
@@ -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)!!,
|
||||||
|
) }
|
||||||
|
}
|
||||||
+58
@@ -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
|
||||||
|
);
|
||||||
+15
@@ -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"})
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -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))
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -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))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+40
@@ -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)
|
||||||
|
}
|
||||||
+13
@@ -33,6 +33,19 @@ class AuthorizationPolicyTest {
|
|||||||
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
|
.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(
|
private fun user(roles: Set<String>) = CurrentUser(
|
||||||
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,55 @@
|
|||||||
openapi: 3.1.0
|
openapi: 3.1.0
|
||||||
info:
|
info:
|
||||||
title: AIOA API
|
title: AIOA API
|
||||||
version: 0.12.0
|
version: 0.16.0
|
||||||
servers:
|
servers:
|
||||||
- url: /api/v1
|
- url: /api/v1
|
||||||
security:
|
security:
|
||||||
- bearerAuth: []
|
- bearerAuth: []
|
||||||
paths:
|
paths:
|
||||||
|
/admin/process-configuration/forms:
|
||||||
|
get: { operationId: listFormVersions, summary: 查询表单版本与发布状态, responses: { "200": { description: 表单版本列表 } } }
|
||||||
|
post: { operationId: createFormVersion, summary: 创建安全校验后的表单草稿版本, responses: { "200": { description: 已创建表单草稿版本 } } }
|
||||||
|
/admin/process-configuration/forms/{key}/{version}/publish:
|
||||||
|
post: { operationId: publishFormVersion, summary: 发布表单版本并退役旧发布版本, parameters: [{ name: key, in: path, required: true, schema: { type: string } }, { name: version, in: path, required: true, schema: { type: integer } }], responses: { "200": { description: 已发布 } } }
|
||||||
|
/admin/process-configuration/bindings:
|
||||||
|
get: { operationId: listProcessBindings, summary: 查询业务表单与流程绑定, responses: { "200": { description: 流程绑定列表 } } }
|
||||||
|
post: { operationId: createProcessBinding, summary: 创建业务流程路由绑定, responses: { "200": { description: 已创建并启用绑定 } } }
|
||||||
|
/admin/process-configuration/bindings/{id}/status:
|
||||||
|
put: { operationId: updateProcessBindingStatus, summary: 启用或停用流程绑定, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 状态已更新 } } }
|
||||||
|
/admin/process-configuration/processes/deploy:
|
||||||
|
post: { operationId: deployProcessTemplate, summary: 校验并部署受约束的可视化流程模板, responses: { "200": { description: Flowable 流程定义已部署 } } }
|
||||||
|
/admin/process-configuration/approver-rules:
|
||||||
|
get: { operationId: listApproverRules, summary: 查询流程设计器可用的组织审批人规则, responses: { "200": { description: 审批人规则目录 } } }
|
||||||
|
/admin/organization/departments:
|
||||||
|
get: { operationId: listAdminDepartments, summary: OA 管理员查询租户部门, responses: { "200": { description: 部门列表 } } }
|
||||||
|
post: { operationId: createAdminDepartment, summary: OA 管理员创建部门, responses: { "200": { description: 已创建部门 } } }
|
||||||
|
/admin/organization/roles:
|
||||||
|
get: { operationId: listAdminRoles, summary: OA 管理员查询租户角色, responses: { "200": { description: 角色列表 } } }
|
||||||
|
/admin/organization/users:
|
||||||
|
get: { operationId: listAdminUsers, summary: OA 管理员查询租户用户与任职角色, responses: { "200": { description: 用户列表 } } }
|
||||||
|
/admin/organization/users/{id}/roles:
|
||||||
|
put: { operationId: replaceAdminUserRoles, summary: OA 管理员替换用户角色, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 角色已更新 } } }
|
||||||
|
/admin/organization/users/{id}/assignment:
|
||||||
|
put: { operationId: replaceAdminUserAssignment, summary: OA 管理员替换用户主任职, parameters: [{ name: id, in: path, required: true, schema: { type: string, format: uuid } }], responses: { "200": { description: 任职已更新 } } }
|
||||||
|
/admin/workflows/definitions:
|
||||||
|
get: { operationId: listWorkflowDefinitions, summary: 查询流程定义及版本, responses: { "200": { description: 流程定义列表 } } }
|
||||||
|
/admin/workflows/instances:
|
||||||
|
get: { operationId: listTenantWorkflowInstances, summary: 查询当前租户流程实例, responses: { "200": { description: 流程实例列表 } } }
|
||||||
|
/admin/metrics:
|
||||||
|
get: { operationId: getTenantOperationsMetrics, summary: 查询业务流程推送运行指标, responses: { "200": { description: 聚合运行指标 } } }
|
||||||
|
/admin/audit-events:
|
||||||
|
get:
|
||||||
|
operationId: listRedactedTenantAuditEvents
|
||||||
|
summary: OA 管理员按租户脱敏查询审计记录
|
||||||
|
parameters:
|
||||||
|
- { name: traceId, in: query, schema: { type: string, maxLength: 128 } }
|
||||||
|
- { name: action, in: query, schema: { type: string, maxLength: 120 } }
|
||||||
|
- { name: resourceType, in: query, schema: { type: string, maxLength: 120 } }
|
||||||
|
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 100 } }
|
||||||
|
responses:
|
||||||
|
"200": { description: 当前租户的脱敏审计记录 }
|
||||||
|
"403": { description: 仅 OA 管理员可查询 }
|
||||||
/devices/register:
|
/devices/register:
|
||||||
post:
|
post:
|
||||||
operationId: registerCurrentDevice
|
operationId: registerCurrentDevice
|
||||||
@@ -631,11 +674,11 @@ components:
|
|||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
items:
|
items:
|
||||||
type: string
|
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:
|
dataScopes:
|
||||||
type: array
|
type: array
|
||||||
uniqueItems: true
|
uniqueItems: true
|
||||||
items: { type: string, enum: [OWN, ASSIGNED] }
|
items: { type: string, enum: [OWN, ASSIGNED, TENANT] }
|
||||||
OrganizationRef:
|
OrganizationRef:
|
||||||
type: object
|
type: object
|
||||||
required: [id, name]
|
required: [id, name]
|
||||||
|
|||||||
@@ -42,6 +42,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"clientId": "aioa-admin-web",
|
||||||
|
"name": "AIOA Admin Web",
|
||||||
|
"enabled": true,
|
||||||
|
"publicClient": true,
|
||||||
|
"standardFlowEnabled": true,
|
||||||
|
"directAccessGrantsEnabled": false,
|
||||||
|
"redirectUris": ["http://localhost:5173/*", "http://127.0.0.1:5173/*"],
|
||||||
|
"webOrigins": ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||||
|
"attributes": { "pkce.code.challenge.method": "S256" },
|
||||||
|
"protocolMappers": [
|
||||||
|
{
|
||||||
|
"name": "tenant-id",
|
||||||
|
"protocol": "openid-connect",
|
||||||
|
"protocolMapper": "oidc-usermodel-attribute-mapper",
|
||||||
|
"consentRequired": false,
|
||||||
|
"config": {
|
||||||
|
"user.attribute": "tenant_id",
|
||||||
|
"claim.name": "tenant_id",
|
||||||
|
"jsonType.label": "String",
|
||||||
|
"id.token.claim": "true",
|
||||||
|
"access.token.claim": "true",
|
||||||
|
"userinfo.token.claim": "true"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"users": [
|
"users": [
|
||||||
|
|||||||
@@ -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 草稿建议成功,并检查确认卡片不会自动提交。
|
||||||
@@ -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 前端业务闭环、权限边界和失败处理的自动化通过结果。
|
||||||
@@ -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。
|
||||||
|
- 创建、发布、启停均写入审计。
|
||||||
@@ -48,6 +48,19 @@
|
|||||||
- [x] 查询本人流程进度
|
- [x] 查询本人流程进度
|
||||||
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
|
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
|
||||||
|
|
||||||
|
## M4:管理与运营闭环
|
||||||
|
|
||||||
|
- [x] OA 管理员按租户脱敏查询审计记录
|
||||||
|
- [x] OA 管理员组织与角色维护 API
|
||||||
|
- [x] 流程定义、版本和运行实例只读管理
|
||||||
|
- [x] Flutter 管理工具入口与权限驱动展示
|
||||||
|
- [x] 业务与推送运行指标仪表板
|
||||||
|
- [x] 表单版本发布、业务流程绑定与服务端路由选择
|
||||||
|
- [x] React Web 可视化表单设计器、移动卡片预览和版本发布
|
||||||
|
- [x] Web OIDC Authorization Code + PKCE 登录、设备会话和 CI 验证
|
||||||
|
- [x] Web 流程选择与表单绑定、条件路由预览和绑定启停
|
||||||
|
- [x] Web 串行、并行、条件流程可视化设计与受约束 Flowable 部署
|
||||||
|
|
||||||
## Definition of Done
|
## Definition of Done
|
||||||
|
|
||||||
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。
|
每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。
|
||||||
|
|||||||
@@ -51,3 +51,16 @@ Flowable 负责任务和流程路径,`business.leave_request` 仍是请假业
|
|||||||
```
|
```
|
||||||
|
|
||||||
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
|
该流程已经验证条件网关、多级串行、并行拆分、并行汇聚和终止事件。后端状态同步按通用多任务语义设计,不会在第一个串行或并行任务完成时提前结束申请。
|
||||||
|
## 可视化流程模板
|
||||||
|
|
||||||
|
Web 管理端提供受约束的流程设计器,可生成并部署以下 Flowable BPMN 模板:
|
||||||
|
|
||||||
|
- 串行审批:审批节点依次执行,任一节点驳回即终止。
|
||||||
|
- 并行会签:多个审批节点同时执行,全部通过后汇聚,任一驳回即终止其他分支。
|
||||||
|
- 条件审批:首个审批通过后按请假时长阈值决定直接结束或增加复核节点。
|
||||||
|
|
||||||
|
流程 Key、节点数量、审批人变量、条件变量和阈值均由后端校验。管理端不能上传任意 BPMN XML、任意表达式或任意审批人脚本。部署成功后,新定义立即出现在流程绑定工作台中。
|
||||||
|
|
||||||
|
审批规则目录由后端提供:部门主管通过发起人的有效主任职与部门内 `manager` 岗位解析;OA、HR 分别通过当前租户的 `oa_admin`、`hr_reviewer` 有效角色解析。所有规则只选择有效任职和正常账号;无人匹配时拒绝提交。并行会签和条件复核强制使用不同规则,避免同一人承担多个职责。
|
||||||
|
|
||||||
|
自动化执行测试使用独立内存 Flowable 引擎验证:短条件路径直接结束、长条件路径创建复核任务、并行会签等待全部审批、任一并行节点驳回时终止其余任务。
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
import 'package:aioa_mobile/app/app.dart';
|
||||||
|
import 'package:aioa_mobile/app/router.dart';
|
||||||
|
import 'package:aioa_mobile/core/config/runtime_config.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:integration_test/integration_test.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
testWidgets('complete MVP frontend business scenario', (tester) async {
|
||||||
|
final api = _E2eApi(RuntimeConfig.e2eAccessToken);
|
||||||
|
await api.registerDevice('90000000-0000-4000-8000-000000000001');
|
||||||
|
await tester.pumpWidget(const ProviderScope(child: AioaApp()));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 10));
|
||||||
|
expect(find.text('工作台'), findsOneWidget);
|
||||||
|
switch (RuntimeConfig.e2eRole) {
|
||||||
|
case 'manager':
|
||||||
|
await _managerScenario(tester);
|
||||||
|
case 'admin':
|
||||||
|
await _adminScenario(tester);
|
||||||
|
default:
|
||||||
|
await _employeeScenario(tester, api);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _employeeScenario(WidgetTester tester, _E2eApi api) async {
|
||||||
|
await tester.tap(find.text('发起请假'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 8));
|
||||||
|
expect(find.text('请假申请'), findsOneWidget);
|
||||||
|
expect(find.text('千问表单助手'), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('type')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('startsAt')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('endsAt')), findsOneWidget);
|
||||||
|
expect(find.byKey(const ValueKey('reason')), findsOneWidget);
|
||||||
|
appRouter.go('/workspace');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
final reason = 'iOS MVP 集成测试 ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final draft = await api.createDraft(reason);
|
||||||
|
await tester.tap(find.text('我的请假申请'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 5));
|
||||||
|
expect(find.textContaining(reason), findsOneWidget);
|
||||||
|
await tester.tap(find.textContaining(reason));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('草稿'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('提交审批'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '确认'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 5));
|
||||||
|
expect(find.text('审批中'), findsOneWidget);
|
||||||
|
|
||||||
|
appRouter.go('/workspace');
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.text('AI 助手'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('AI 流程助手'), findsOneWidget);
|
||||||
|
await tester.enterText(find.byType(TextField).first, '我最近提交的请假到哪一步了?');
|
||||||
|
await tester.tap(find.text('查询进度'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 8));
|
||||||
|
expect(find.textContaining('查询失败'), findsWidgets);
|
||||||
|
|
||||||
|
await tester.tap(find.text('待办'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.textContaining('通知'), findsWidgets);
|
||||||
|
await tester.tap(find.text('我的'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 5));
|
||||||
|
expect(find.text('登录设备'), findsOneWidget);
|
||||||
|
expect(draft['id'], isNotNull);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _managerScenario(WidgetTester tester) async {
|
||||||
|
final employee = _E2eApi(RuntimeConfig.e2eEmployeeToken);
|
||||||
|
await employee.registerDevice('90000000-0000-4000-8000-000000000002');
|
||||||
|
final reason = '主管 iOS 审批测试 ${DateTime.now().millisecondsSinceEpoch}';
|
||||||
|
final draft = await employee.createDraft(
|
||||||
|
reason,
|
||||||
|
deviceId: '90000000-0000-4000-8000-000000000002',
|
||||||
|
);
|
||||||
|
await employee.submitDraft(
|
||||||
|
draft,
|
||||||
|
deviceId: '90000000-0000-4000-8000-000000000002',
|
||||||
|
);
|
||||||
|
await tester.tap(find.text('待办'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 8));
|
||||||
|
expect(find.text(reason), findsOneWidget);
|
||||||
|
final taskCard = find.ancestor(
|
||||||
|
of: find.text(reason),
|
||||||
|
matching: find.byType(Card),
|
||||||
|
);
|
||||||
|
await tester.tap(find.descendant(of: taskCard, matching: find.text('批准')));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
await tester.tap(find.widgetWithText(FilledButton, '确认批准'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 8));
|
||||||
|
expect(find.text('已批准'), findsOneWidget);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _adminScenario(WidgetTester tester) async {
|
||||||
|
await tester.tap(find.text('我的'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 8));
|
||||||
|
expect(find.text('OA 管理中心'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('OA 管理中心'));
|
||||||
|
await tester.pumpAndSettle(const Duration(seconds: 10));
|
||||||
|
expect(find.text('指标'), findsOneWidget);
|
||||||
|
expect(find.text('组织'), findsOneWidget);
|
||||||
|
expect(find.text('流程'), findsOneWidget);
|
||||||
|
expect(find.text('审计'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('组织'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('新增部门'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('流程'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('流程定义'), findsOneWidget);
|
||||||
|
await tester.tap(find.text('审计'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.byType(Card), findsWidgets);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _E2eApi {
|
||||||
|
_E2eApi(this.token);
|
||||||
|
final String token;
|
||||||
|
String get base => RuntimeConfig.apiBaseUrl;
|
||||||
|
Map<String, String> headers([String? device]) => {
|
||||||
|
'Authorization': 'Bearer $token',
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-AIOA-Device-Id': ?device,
|
||||||
|
};
|
||||||
|
static const device = '90000000-0000-4000-8000-000000000001';
|
||||||
|
Future<void> registerDevice(String id) async {
|
||||||
|
final r = await http.post(
|
||||||
|
Uri.parse('$base/devices/register'),
|
||||||
|
headers: headers(),
|
||||||
|
body: jsonEncode({
|
||||||
|
'id': id,
|
||||||
|
'name': 'iOS integration test',
|
||||||
|
'platform': 'IOS',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.statusCode, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<Map<String, Object?>> createDraft(
|
||||||
|
String reason, {
|
||||||
|
String deviceId = device,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now().toUtc().add(const Duration(days: 2));
|
||||||
|
final r = await http.post(
|
||||||
|
Uri.parse('$base/leave-requests/drafts'),
|
||||||
|
headers: {
|
||||||
|
...headers(deviceId),
|
||||||
|
'Idempotency-Key': 'ios-e2e-${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
},
|
||||||
|
body: jsonEncode({
|
||||||
|
'type': 'PERSONAL',
|
||||||
|
'startsAt': now.toIso8601String(),
|
||||||
|
'endsAt': now.add(const Duration(hours: 4)).toIso8601String(),
|
||||||
|
'reason': reason,
|
||||||
|
'version': 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(r.statusCode, 201);
|
||||||
|
return Map<String, Object?>.from(jsonDecode(r.body) as Map);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> submitDraft(
|
||||||
|
Map<String, Object?> draft, {
|
||||||
|
String deviceId = device,
|
||||||
|
}) async {
|
||||||
|
final r = await http.post(
|
||||||
|
Uri.parse('$base/leave-requests/${draft['id']}/submit'),
|
||||||
|
headers: {
|
||||||
|
...headers(deviceId),
|
||||||
|
'Idempotency-Key':
|
||||||
|
'ios-submit-${DateTime.now().microsecondsSinceEpoch}',
|
||||||
|
},
|
||||||
|
body: jsonEncode({'version': draft['version']}),
|
||||||
|
);
|
||||||
|
expect(r.statusCode, 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,6 +105,8 @@ PODS:
|
|||||||
- GoogleUtilities/UserDefaults (8.1.0):
|
- GoogleUtilities/UserDefaults (8.1.0):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
|
- integration_test (0.0.1):
|
||||||
|
- Flutter
|
||||||
- nanopb (3.30910.0):
|
- nanopb (3.30910.0):
|
||||||
- nanopb/decode (= 3.30910.0)
|
- nanopb/decode (= 3.30910.0)
|
||||||
- nanopb/encode (= 3.30910.0)
|
- nanopb/encode (= 3.30910.0)
|
||||||
@@ -126,6 +128,7 @@ DEPENDENCIES:
|
|||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
- flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`)
|
- flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`)
|
||||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
- 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`)
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
|
||||||
SPEC REPOS:
|
SPEC REPOS:
|
||||||
@@ -158,6 +161,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_appauth/ios"
|
:path: ".symlinks/plugins/flutter_appauth/ios"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
|
integration_test:
|
||||||
|
:path: ".symlinks/plugins/integration_test/ios"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
|
||||||
@@ -178,6 +183,7 @@ SPEC CHECKSUMS:
|
|||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
|
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
|
||||||
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
|
||||||
|
|||||||
@@ -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/requests/presentation/leave_request_list_page.dart';
|
||||||
import 'package:aioa_mobile/features/tasks/presentation/tasks_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/workspace/presentation/workspace_page.dart';
|
||||||
|
import 'package:aioa_mobile/features/admin/presentation/admin_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
@@ -30,6 +31,7 @@ final appRouter = GoRouter(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
|
||||||
|
GoRoute(path: '/admin', builder: (_, _) => const AdminPage()),
|
||||||
GoRoute(
|
GoRoute(
|
||||||
path: '/leave/:id',
|
path: '/leave/:id',
|
||||||
builder: (_, state) =>
|
builder: (_, state) =>
|
||||||
|
|||||||
@@ -19,11 +19,27 @@ class AuthSessionController extends AsyncNotifier<AuthSession?> {
|
|||||||
static const _appAuth = FlutterAppAuth();
|
static const _appAuth = FlutterAppAuth();
|
||||||
|
|
||||||
@override
|
@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 {
|
Future<void> login() async {
|
||||||
state = const AsyncLoading();
|
state = const AsyncLoading();
|
||||||
state = await AsyncValue.guard(() async {
|
try {
|
||||||
final result = await _appAuth.authorizeAndExchangeCode(
|
final result = await _appAuth.authorizeAndExchangeCode(
|
||||||
AuthorizationTokenRequest(
|
AuthorizationTokenRequest(
|
||||||
oidcClientId,
|
oidcClientId,
|
||||||
@@ -34,8 +50,12 @@ class AuthSessionController extends AsyncNotifier<AuthSession?> {
|
|||||||
allowInsecureConnections: oidcIssuer.startsWith('http://'),
|
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 {
|
Future<void> logout() async {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class LoginPage extends ConsumerWidget {
|
|||||||
if (auth.hasError) ...[
|
if (auth.hasError) ...[
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
'登录失败:${auth.error}',
|
'登录失败,请检查网络后重试',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Theme.of(context).colorScheme.error,
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ class RuntimeConfig {
|
|||||||
static const _configuredOidcIssuer = String.fromEnvironment(
|
static const _configuredOidcIssuer = String.fromEnvironment(
|
||||||
'AIOA_OIDC_ISSUER',
|
'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
|
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
|
||||||
? _configuredApiBaseUrl
|
? _configuredApiBaseUrl
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ class DynamicFormCard extends StatelessWidget {
|
|||||||
onChanged: (value) => onChanged(control.field, value),
|
onChanged: (value) => onChanged(control.field, value),
|
||||||
),
|
),
|
||||||
FormControlType.dateTime => _DateTimeControl(
|
FormControlType.dateTime => _DateTimeControl(
|
||||||
|
key: ValueKey(control.field),
|
||||||
label: label,
|
label: label,
|
||||||
value: state.values[control.field] as String?,
|
value: state.values[control.field] as String?,
|
||||||
errorText: error,
|
errorText: error,
|
||||||
@@ -109,6 +110,7 @@ class DynamicFormCard extends StatelessWidget {
|
|||||||
|
|
||||||
class _DateTimeControl extends StatelessWidget {
|
class _DateTimeControl extends StatelessWidget {
|
||||||
const _DateTimeControl({
|
const _DateTimeControl({
|
||||||
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
required this.value,
|
required this.value,
|
||||||
required this.errorText,
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
|
import 'package:aioa_mobile/features/profile/application/device_controller.dart';
|
||||||
import 'package:intl/intl.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 {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
@@ -10,6 +12,8 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final devices = ref.watch(deviceListProvider);
|
final devices = ref.watch(deviceListProvider);
|
||||||
|
final permissions =
|
||||||
|
ref.watch(currentPermissionsProvider).value ?? const <String>{};
|
||||||
return ListView(
|
return ListView(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.all(18),
|
||||||
children: [
|
children: [
|
||||||
@@ -21,6 +25,14 @@ class ProfilePage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
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),
|
Text('登录设备', style: Theme.of(context).textTheme.titleMedium),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
...devices.when(
|
...devices.when(
|
||||||
|
|||||||
@@ -246,6 +246,11 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "12.0.1"
|
version: "12.0.1"
|
||||||
|
flutter_driver:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
flutter_lints:
|
flutter_lints:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
@@ -336,6 +341,11 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
version: "4.0.0"
|
||||||
|
fuchsia_remote_debug_protocol:
|
||||||
|
dependency: transitive
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -384,6 +394,11 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.1.2"
|
version: "4.1.2"
|
||||||
|
integration_test:
|
||||||
|
dependency: "direct dev"
|
||||||
|
description: flutter
|
||||||
|
source: sdk
|
||||||
|
version: "0.0.0"
|
||||||
intl:
|
intl:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -600,6 +615,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.5.2"
|
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:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -773,6 +796,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.1"
|
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:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -869,6 +900,14 @@ packages:
|
|||||||
url: "https://pub.flutter-io.cn"
|
url: "https://pub.flutter-io.cn"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.3"
|
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:
|
webkit_inspection_protocol:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ dependencies:
|
|||||||
shared_preferences: ^2.5.3
|
shared_preferences: ^2.5.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
|
integration_test:
|
||||||
|
sdk: flutter
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
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'),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -5,3 +5,5 @@
|
|||||||
`verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,注册脚本设备并验证健康检查、Token 获取、设备会话和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。
|
`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。
|
`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,并自动构造互相隔离的业务数据。
|
||||||
|
|||||||
Executable
+53
@@ -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.'
|
||||||
Reference in New Issue
Block a user