Compare commits

15 Commits

Author SHA1 Message Date
selfrelease 75bc867e4c chore: gitignore backend/boot/bin/ 2026-07-19 09:38:14 +08:00
selfrelease 57c8a26147 feat: LLM多阶段交互式Schema生成 + UI布局优化
- AI Service: 多阶段对话模型(理解→澄清→生成→校验→确认),支持对话历史和当前Schema上下文
- Backend: 适配多阶段AI模型,Gateway/Service/Controller传递history和currentSchema
- Frontend: AiDesignerChat组件重写,支持阶段徽章、快速回复、Schema校验展示
- Frontend: 表单/流程设计器布局优化,AI助手和Schema预览作为独立列
- Frontend: 流程设计器元数据和模式选择器合并为一行
- Keycloak: 自定义登录主题(中文化)
- 修复CSS媒体查询括号平衡问题
2026-07-19 09:36:42 +08:00
selfrelease a2238bc853 fix: configure JDK 21 toolchain path for Gradle 2026-07-19 08:14:20 +08:00
selfrelease ca42abea74 feat: update process designer, controller, and docs 2026-07-19 07:55:12 +08:00
selfrelease 34a279721e feat: add organization approval rules 2026-07-19 07:49:37 +08:00
selfrelease cd72ec3756 test: verify generated workflow execution 2026-07-18 23:06:23 +08:00
selfrelease 9c366cc872 feat: add visual process designer 2026-07-18 22:56:29 +08:00
selfrelease 89c44a61a7 feat: add process binding designer 2026-07-18 22:45:06 +08:00
selfrelease 342a1fcb33 feat: add web form designer 2026-07-18 22:25:23 +08:00
selfrelease 15abd8a32f feat: add versioned form and process routing 2026-07-18 21:26:44 +08:00
selfrelease 24101398d8 test: add complete iOS MVP business suite 2026-07-18 20:54:23 +08:00
selfrelease e19bdb8728 fix: harden iOS authentication recovery 2026-07-18 20:04:00 +08:00
selfrelease b311be6aa6 feat: complete MVP administration tools 2026-07-18 19:47:20 +08:00
selfrelease d0a2f4f923 feat: add redacted tenant audit queries 2026-07-18 19:27:29 +08:00
selfrelease 090a7e33ce feat: complete leave approval MVP 2026-07-18 19:20:07 +08:00
193 changed files with 13921 additions and 104 deletions
+6
View File
@@ -9,3 +9,9 @@ KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=change-me
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=change-me-now
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_BUCKET=aioa-attachments
QWEN_API_KEY=
QWEN_MODEL=qwen-plus
AI_SERVICE_URL=http://127.0.0.1:8000
FIREBASE_CREDENTIALS_FILE=
+11
View File
@@ -24,8 +24,19 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
# Deployment credentials
firebase-service-account*.json
**/firebase-service-account*.json
# Local data
.local/
coverage/
reports/
node_modules/
admin-web/dist/
*.tsbuildinfo
admin-web/vite.config.js
admin-web/vite.config.d.ts
backend/boot/bin/
+141
View File
@@ -0,0 +1,141 @@
stages:
- verify
- build
default:
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
backend-test:
stage: verify
image: eclipse-temurin:21-jdk
variables:
GRADLE_USER_HOME: $CI_PROJECT_DIR/.cache/gradle
cache:
key:
files:
- backend/gradle/wrapper/gradle-wrapper.properties
paths:
- .cache/gradle/caches/
- .cache/gradle/wrapper/
before_script:
- cd backend
- chmod +x gradlew
script:
- ./gradlew --no-daemon test
artifacts:
when: always
expire_in: 7 days
reports:
junit: backend/boot/build/test-results/test/*.xml
ai-service-test:
stage: verify
image: python:3.12-slim
variables:
PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
QWEN_API_KEY: ""
cache:
key:
files:
- ai-service/pyproject.toml
paths:
- .cache/pip/
before_script:
- cd ai-service
- python -m pip install --upgrade pip
- python -m pip install -e '.[dev]'
script:
- python -m compileall -q app tests
- mkdir -p reports
- pytest --junitxml=reports/pytest.xml
artifacts:
when: always
expire_in: 7 days
reports:
junit: ai-service/reports/pytest.xml
flutter-verify:
stage: verify
image: ghcr.io/cirruslabs/flutter:stable
variables:
PUB_CACHE: $CI_PROJECT_DIR/.cache/pub
cache:
key:
files:
- mobile/pubspec.lock
paths:
- .cache/pub/
before_script:
- cd mobile
- flutter pub get
script:
- dart format --output=none --set-exit-if-changed lib test
- flutter analyze
- mkdir -p reports
- flutter test --machine > reports/flutter-test.json
after_script:
- test -f mobile/reports/flutter-test.json || true
artifacts:
when: always
expire_in: 7 days
paths:
- mobile/reports/flutter-test.json
admin-web-verify:
stage: verify
image: node:24-alpine
cache:
key:
files:
- admin-web/package-lock.json
paths:
- .cache/npm/
before_script:
- cd admin-web
- npm ci --cache ../.cache/npm
script:
- npm test
- npm run build
artifacts:
expire_in: 7 days
paths:
- admin-web/dist/
android-debug-build:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
needs:
- flutter-verify
variables:
PUB_CACHE: $CI_PROJECT_DIR/.cache/pub
cache:
key:
files:
- mobile/pubspec.lock
paths:
- .cache/pub/
- mobile/.gradle/
before_script:
- cd mobile
- flutter pub get
script:
- flutter build apk --debug
artifacts:
expire_in: 7 days
paths:
- mobile/build/app/outputs/flutter-apk/app-debug.apk
# iOS 构建需要项目自行配置带 Xcode 和 Flutter 的 macOS GitLab Runner。
# 配置后可复制 flutter-verify 作业,并使用 tags: [macos] 及:
# flutter build ios --simulator --no-codesign
+41
View File
@@ -19,6 +19,7 @@ AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实
```text
backend/ Kotlin + Spring Boot 模块化单体
mobile/ Flutter 移动客户端
admin-web/ React + TypeScript 表单设计与流程配置管理端
ai-service/ Python AI 服务
contracts/ OpenAPI 与事件契约
deploy/ 本地与部署配置
@@ -38,6 +39,14 @@ scripts/ 开发辅助脚本
本地 PostgreSQL 暴露在 `127.0.0.1:15432`,避免与系统或其他项目常用的 `5432` 端口冲突。
千问 AI 服务通过 `QWEN_API_KEY` 环境变量读取密钥。密钥只能放在被 Git 忽略的本地 `.env` 或部署密钥系统中,不得写入 `.env.example`、代码、镜像或 Flutter 客户端。启动 AI 服务:
```bash
docker compose --env-file .env -f deploy/compose/compose.yaml up -d ai-service
```
Android 模拟器登录前运行 `scripts/dev-android-reverse.sh`,使 Keycloak 的固定开发 Issuer `http://localhost:8081/realms/aioa` 与 Android 回调环境保持一致,并允许访问 MinIO 预签名地址。iOS Simulator 可直接使用默认本机地址。
后端测试:
```bash
@@ -49,4 +58,36 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
当前实施范围和验收标准见 [docs/product/mvp.md](docs/product/mvp.md)。
一键执行本地提交前验证:
```bash
./scripts/verify-all.sh
```
需要同时生成 Android 和 iOS Simulator 构建产物时:
```bash
AIOA_FULL_BUILD=1 ./scripts/verify-all.sh
```
Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engineering/mobile-schema-forms.md](docs/engineering/mobile-schema-forms.md)。
Web 管理端已实现可视化表单设计、实时移动卡片预览和版本发布。运行与验证方式见 [admin-web/README.md](admin-web/README.md)。
## 持续集成
仓库根目录的 `.gitlab-ci.yml` 默认执行:
- JDK 21 后端测试,并上传 JUnit 报告;
- Python 3.12 AI 服务编译检查与测试;
- Flutter 格式检查、静态分析和测试;
- React 管理端单元测试和生产构建;
- Android Debug APK 构建与产物归档。
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
## 移动推送
移动端使用 Firebase Cloud Messaging,同时承载 Android FCM 与 iOS APNs。生产构建需要分别提供 Firebase 项目的 `google-services.json``GoogleService-Info.plist`,后端通过 `FIREBASE_CREDENTIALS_FILE` 指向 Firebase Admin 服务账号 JSON。凭证文件必须由部署密钥系统挂载,不得提交到仓库。
未配置 Firebase 时,Flutter 会自动降级,站内通知仍然可用;后端推送 outbox 会保留并延迟重试。FlutterFire 当前最低支持 iOS 15,因此项目部署目标已统一调整为 iOS 15。
+46
View File
@@ -0,0 +1,46 @@
# AIOA 管理设计中心
React + TypeScript 管理端,当前提供两个配置工作台:
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
## 本地运行
先从仓库根目录启动 PostgreSQL、Keycloak 和后端:
```bash
docker compose --env-file .env.example -f deploy/compose/compose.yaml up -d postgres keycloak
cd backend
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
export GRADLE_USER_HOME=/tmp/aioa-gradle-home
./gradlew --no-daemon :boot:bootRun
```
再启动管理端:
```bash
cd admin-web
npm ci
npm run dev -- --host 127.0.0.1
```
访问 `http://127.0.0.1:5173`,通过 Keycloak 登录。默认 OIDC Issuer 为 `http://localhost:8081/realms/aioa`,后端 API 为 `http://localhost:8080/api/v1`;需要覆盖时可设置:
```bash
VITE_OIDC_ISSUER=http://localhost:8081/realms/aioa
VITE_API_BASE_URL=http://localhost:8080/api/v1
```
## 验证
```bash
npm test
npm run build
```
Keycloak Realm 首次导入时会创建 `aioa-admin-web` 公共客户端并启用 Authorization Code + PKCE S256。若本机 Keycloak 已在加入该客户端之前启动,需要在开发环境重建 Keycloak 容器或通过管理控制台补入同名客户端。
+1
View File
@@ -0,0 +1 @@
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
+2959
View File
File diff suppressed because it is too large Load Diff
+26
View File
@@ -0,0 +1,26 @@
{
"name": "aioa-admin-web",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"test": "vitest run"
},
"dependencies": {
"oidc-client-ts": "^3.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1"
},
"devDependencies": {
"@testing-library/react": "^16.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^4.7.0",
"jsdom": "^26.1.0",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vitest": "^3.2.4"
}
}
+150
View File
@@ -0,0 +1,150 @@
import { useState, useRef, useEffect } from 'react';
import { api } from './api';
import type { Field, ControlType } from './schema';
export type DesignerStage = 'UNDERSTANDING' | 'CLARIFYING' | 'GENERATING' | 'VALIDATING' | 'CONFIRMING';
export type DesignerSuggestion = {
stage: DesignerStage;
formTitle?: string;
formKey?: string;
fields: {
key: string; label: string; control: string; required: boolean;
placeholder?: string; options?: string[]; helperText?: string;
}[];
process?: {
mode: string;
steps: { name: string; assigneeVariable: string }[];
conditionThresholdDays?: number | null;
};
summary: string;
understanding: string;
assumptions: string[];
needsClarification: string[];
validationIssues?: { field: string; issue: string; severity: string }[];
schemaReady: boolean;
};
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
suggestion?: DesignerSuggestion;
};
export type ApplySuggestion = (s: DesignerSuggestion) => void;
const stageLabels: Record<DesignerStage, string> = {
UNDERSTANDING: '🧠 理解需求',
CLARIFYING: '❓ 澄清确认',
GENERATING: '📋 生成 Schema',
VALIDATING: '✅ 校验 Schema',
CONFIRMING: '🎯 等待确认',
};
export function AiDesignerChat({ onApply, contextLabel, getCurrentSchema }: { onApply: ApplySuggestion; contextLabel: string; getCurrentSchema?: () => string }) {
const [messages, setMessages] = useState<ChatMessage[]>([
{ role: 'assistant', content: '你好!我是 AI 设计助手。用自然语言描述你想要的表单或审批流程,我来帮你生成。\n\n例如:\n• "报销申请表单,包含报销人、金额、日期、事由"\n• "报销审批:先部门主管审批,超过5000元加财务复核,最后OA管理员审批"' },
]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [currentStage, setCurrentStage] = useState<DesignerStage>('UNDERSTANDING');
const scrollRef = useRef<HTMLDivElement>(null);
useEffect(() => {
scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: 'smooth' });
}, [messages, loading]);
function buildHistory(msgs: ChatMessage[]) {
return msgs.slice(1).map(m => ({ role: m.role, content: m.content }));
}
async function send(presetText?: string) {
const text = (presetText ?? input).trim();
if (!text || loading) return;
setInput('');
const next = [...messages, { role: 'user' as const, content: text }];
setMessages(next);
setLoading(true);
try {
const result = await api<{ suggestion: DesignerSuggestion; model: string }>('/ai/designer-suggestions', {
method: 'POST',
body: JSON.stringify({
message: text,
history: buildHistory(next.slice(0, -1)),
currentSchema: getCurrentSchema?.() ?? null,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || 'Asia/Shanghai',
}),
});
const s = result.suggestion;
setCurrentStage(s.stage);
const parts = formatSuggestionMessage(s);
setMessages([...next, { role: 'assistant', content: parts, suggestion: s }]);
} catch (e) {
setMessages([...next, { role: 'assistant', content: `⚠️ ${e instanceof Error ? e.message : 'AI 服务暂时不可用'}` }]);
} finally {
setLoading(false);
}
}
return <div className="aiChatPanel">
<div className="aiChatHeader">
<span> AI </span>
<small>{contextLabel}</small>
<span className="aiStageBadge">{stageLabels[currentStage]}</span>
</div>
<div className="aiChatMessages" ref={scrollRef}>
{messages.map((m, i) => <div key={i} className={`aiMsg ${m.role}`}>
<div className="aiMsgContent">{m.content}</div>
{m.suggestion && m.suggestion.fields.length > 0 && (m.suggestion.stage === 'GENERATING' || m.suggestion.stage === 'VALIDATING' || m.suggestion.stage === 'CONFIRMING') && (
<button className="aiApplyBtn" onClick={() => onApply(m.suggestion!)}> </button>
)}
{m.suggestion && m.suggestion.needsClarification.length > 0 && m.suggestion.stage === 'CLARIFYING' && (
<div className="aiQuickReplies">
{m.suggestion.needsClarification.map((q, qi) => <button key={qi} className="aiQuickReply" onClick={() => void send(q)}>{q}</button>)}
</div>
)}
{m.suggestion && m.suggestion.schemaReady && (
<div className="aiSchemaReady"> Schema </div>
)}
</div>)}
{loading && <div className="aiMsg assistant"><div className="aiMsgContent aiTyping"></div></div>}
</div>
<div className="aiChatInput">
<textarea
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); void send(); } }}
placeholder={currentStage === 'CONFIRMING' ? '输入"确认"或描述调整…' : '描述你想要的表单或流程…'}
rows={2}
disabled={loading}
/>
<button onClick={() => void send()} disabled={loading || !input.trim()}></button>
</div>
</div>;
}
function formatSuggestionMessage(s: DesignerSuggestion): string {
const parts: string[] = [];
if (s.understanding) parts.push(`🧠 我的理解:${s.understanding}`);
if (s.needsClarification.length > 0) parts.push(`❓ 需要澄清:\n${s.needsClarification.map((q, i) => `${i + 1}. ${q}`).join('\n')}`);
if (s.fields.length > 0) parts.push(`📋 表单字段(${s.fields.length} 个):\n${s.fields.map(f => `${f.label}${f.control}${f.required ? ',必填' : ''}`).join('\n')}`);
if (s.process) parts.push(`🔄 审批流程(${s.process.mode}):${s.process.steps.map(st => st.name).join(' → ')}`);
if (s.validationIssues && s.validationIssues.length > 0) parts.push(`⚠️ 校验问题:\n${s.validationIssues.map(v => ` • [${v.severity}] ${v.field}: ${v.issue}`).join('\n')}`);
if (s.assumptions.length > 0) parts.push(`💡 假设:${s.assumptions.join('')}`);
if (s.summary) parts.push(`📝 ${s.summary}`);
if (s.schemaReady) parts.push(`✅ Schema 已就绪,请确认或调整`);
return parts.join('\n\n') || '已处理';
}
export function suggestionToFields(s: DesignerSuggestion): Field[] {
return s.fields.map(f => ({
id: crypto.randomUUID(),
key: f.key,
label: f.label,
control: (['text', 'textArea', 'select', 'dateTime', 'number'].includes(f.control) ? f.control : 'text') as ControlType,
required: f.required,
placeholder: f.placeholder,
options: f.options,
helperText: f.helperText,
}));
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { userManager } from './auth';
import { buildSchemas, type ControlType, type Field } from './schema';
import { ProcessBindings } from './ProcessBindings';
import { ProcessDesigner } from './ProcessDesigner';
import { AiDesignerChat, suggestionToFields, type DesignerSuggestion } from './AiDesignerChat';
type FormVersion = { form_key: string; version: number; status: string; published_at?: string };
const palette: { control: ControlType; label: string }[] = [
{ control: 'text', label: '单行文本' }, { control: 'textArea', label: '多行文本' },
{ control: 'select', label: '下拉选择' }, { control: 'dateTime', label: '日期时间' },
];
export function App() {
const [authenticated, setAuthenticated] = useState(false);
const [loading, setLoading] = useState(true);
useEffect(() => {
(async () => {
if (location.pathname === '/callback') { await userManager.signinRedirectCallback(); history.replaceState({}, '', '/'); }
const user = await userManager.getUser(); setAuthenticated(Boolean(user && !user.expired)); setLoading(false);
})().catch(() => setLoading(false));
}, []);
if (loading) return <div className="center"></div>;
if (!authenticated) return <Login />;
return <Workspace onLogout={() => userManager.signoutRedirect()} />;
}
function Login() {
return <main className="login"><div className="brandMark">A</div><h1>AIOA </h1><p> Flowable </p><button onClick={() => userManager.signinRedirect()}>使 Keycloak </button></main>;
}
function Workspace({ onLogout }: { onLogout: () => void }) {
const [view, setView] = useState<'forms' | 'processes' | 'bindings'>('forms');
return <div className="workspace"><header><div><strong>AIOA</strong><span></span></div><nav><button className={view === 'forms' ? 'navActive' : 'navButton'} onClick={() => setView('forms')}></button><button className={view === 'processes' ? 'navActive' : 'navButton'} onClick={() => setView('processes')}></button><button className={view === 'bindings' ? 'navActive' : 'navButton'} onClick={() => setView('bindings')}></button></nav><button className="ghost" onClick={onLogout}>退</button></header>{view === 'forms' ? <Designer /> : view === 'processes' ? <ProcessDesigner /> : <ProcessBindings />}</div>;
}
function Designer() {
const [formKey, setFormKey] = useState('leave-request');
const [title, setTitle] = useState('请假申请');
const [fields, setFields] = useState<Field[]>([
{ id: crypto.randomUUID(), key: 'type', label: '请假类型', control: 'select', required: true, options: ['PERSONAL', 'SICK', 'ANNUAL'] },
{ id: crypto.randomUUID(), key: 'startsAt', label: '开始时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'endsAt', label: '结束时间', control: 'dateTime', required: true },
{ id: crypto.randomUUID(), key: 'reason', label: '请假原因', control: 'textArea', required: true },
]);
const [selected, setSelected] = useState(fields[0].id);
const [versions, setVersions] = useState<FormVersion[]>([]);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const schemas = useMemo(() => buildSchemas(title, fields), [title, fields]);
const selectedField = fields.find(f => f.id === selected);
const loadVersions = () => api<FormVersion[]>('/admin/process-configuration/forms').then(setVersions).catch(e => setMessage(e.message));
useEffect(() => { void loadVersions(); }, []);
function applySuggestion(s: DesignerSuggestion) {
if (s.formTitle) setTitle(s.formTitle);
if (s.formKey) setFormKey(s.formKey);
if (s.fields.length) { const newFields = suggestionToFields(s); setFields(newFields); setSelected(newFields[0]?.id ?? ''); }
}
function add(control: ControlType) {
const index = fields.length + 1;
const field: Field = { id: crypto.randomUUID(), key: `field${index}`, label: `字段 ${index}`, control, required: false, ...(control === 'select' ? { options: ['OPTION_1', 'OPTION_2'] } : {}) };
setFields([...fields, field]); setSelected(field.id);
}
function update(patch: Partial<Field>) { setFields(fields.map(f => f.id === selected ? { ...f, ...patch } : f)); }
function move(id: string, delta: number) { const from = fields.findIndex(f => f.id === id), to = from + delta; if (to < 0 || to >= fields.length) return; const next = [...fields]; [next[from], next[to]] = [next[to], next[from]]; setFields(next); }
function dropField(event: React.DragEvent, targetId: string) { event.preventDefault(); const sourceId = event.dataTransfer.getData('field'); const source = fields.findIndex(f => f.id === sourceId), target = fields.findIndex(f => f.id === targetId); if (source < 0 || target < 0) return; const next = [...fields]; const [item] = next.splice(source, 1); next.splice(target, 0, item); setFields(next); }
async function save() {
try { const result = await api<{ version: number }>('/admin/process-configuration/forms', { method: 'POST', body: JSON.stringify({ formKey, ...schemas }) }); setMessage(`草稿 v${result.version} 已保存`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '保存失败'); }
}
async function publish(version: number) {
try { await api(`/admin/process-configuration/forms/${formKey}/${version}/publish`, { method: 'POST' }); setMessage(`v${version} 已发布`); await loadVersions(); }
catch (e) { setMessage(e instanceof Error ? e.message : '发布失败'); }
}
return <div className="appShell">
<div className="subHeader"><div><strong></strong><span>Schema </span></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={save}>稿</button></div></div>
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<section className="meta"><label> Key<input value={formKey} onChange={e => setFormKey(e.target.value)} /></label><label><input value={title} onChange={e => setTitle(e.target.value)} /></label></section>
<main className={`designer ${showAI ? 'designerWithAI' : ''}`}>
<aside><h2></h2>{palette.map(p => <button className="palette" key={p.control} onClick={() => add(p.control)}> {p.label}</button>)}<h2></h2>{versions.filter(v => v.form_key === formKey).map(v => <div className="version" key={v.version}><span>v{v.version} · {v.status}</span>{v.status === 'DRAFT' && <button onClick={() => publish(v.version)}></button>}</div>)}</aside>
<section className="canvas"><div className="phone"><div className="phoneHeader">{title}</div>{fields.map((f, i) => <div className={`field ${selected === f.id ? 'selected' : ''}`} key={f.id} draggable onDragStart={e => e.dataTransfer.setData('field', f.id)} onDragOver={e => e.preventDefault()} onDrop={e => dropField(e, f.id)} onClick={() => setSelected(f.id)}><label>{f.label}{f.required && ' *'}</label>{f.control === 'textArea' ? <textarea disabled placeholder={f.placeholder} /> : f.control === 'select' ? <select disabled><option>{f.options?.[0] ?? '请选择'}</option></select> : <input disabled placeholder={f.control === 'dateTime' ? '请选择日期和时间' : f.placeholder} />}<div className="fieldActions"><button onClick={e => { e.stopPropagation(); move(f.id, -1); }}></button><button onClick={e => { e.stopPropagation(); move(f.id, 1); }}></button><button onClick={e => { e.stopPropagation(); setFields(fields.filter(x => x.id !== f.id)); }}>×</button></div><small>{i + 1}</small></div>)}</div></section>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="表单设计" getCurrentSchema={() => JSON.stringify(schemas)} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify(schemas, null, 2)}</pre></aside>}
<aside className="properties"><h2></h2>{selectedField ? <><label> Key<input value={selectedField.key} onChange={e => update({ key: e.target.value })} /></label><label><input value={selectedField.label} onChange={e => update({ label: e.target.value })} /></label><label><input value={selectedField.placeholder ?? ''} onChange={e => update({ placeholder: e.target.value })} /></label><label className="check"><input type="checkbox" checked={selectedField.required} onChange={e => update({ required: e.target.checked })} /></label>{selectedField.control === 'select' && <label><textarea value={(selectedField.options ?? []).join('\n')} onChange={e => update({ options: e.target.value.split('\n') })} /></label>}</> : <p></p>}</aside>
</main>
</div>;
}
+90
View File
@@ -0,0 +1,90 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
type FormVersion = { form_key: string; version: number; status: string };
type ProcessDefinition = { id: string; key: string; name?: string; version: number; suspended: boolean };
type Binding = {
id: string; business_type: string; form_key: string; form_version: number;
process_definition_key: string; leave_type?: string; min_duration_minutes?: number;
max_duration_minutes?: number; priority: number; status: 'ACTIVE' | 'INACTIVE';
};
const leaveTypes = [{ value: '', label: '全部请假类型' }, { value: 'PERSONAL', label: '事假' }, { value: 'SICK', label: '病假' }, { value: 'ANNUAL', label: '年假' }];
const minutesToDays = (value?: number) => value == null ? '不限' : `${Number((value / 480).toFixed(1))}`;
export function ProcessBindings() {
const [forms, setForms] = useState<FormVersion[]>([]);
const [definitions, setDefinitions] = useState<ProcessDefinition[]>([]);
const [bindings, setBindings] = useState<Binding[]>([]);
const [message, setMessage] = useState('');
const [formKey, setFormKey] = useState('leave-request');
const published = forms.filter(form => form.status === 'PUBLISHED');
const selectedVersions = published.filter(form => form.form_key === formKey);
const [formVersion, setFormVersion] = useState(1);
const [processKey, setProcessKey] = useState('leaveApproval');
const [leaveType, setLeaveType] = useState('');
const [minDays, setMinDays] = useState('');
const [maxDays, setMaxDays] = useState('');
const [priority, setPriority] = useState(100);
async function load() {
try {
const [formResult, definitionResult, bindingResult] = await Promise.all([
api<FormVersion[]>('/admin/process-configuration/forms'),
api<ProcessDefinition[]>('/admin/workflows/definitions'),
api<Binding[]>('/admin/process-configuration/bindings'),
]);
setForms(formResult); setDefinitions(definitionResult); setBindings(bindingResult);
const firstPublished = formResult.find(form => form.status === 'PUBLISHED');
if (firstPublished) { setFormKey(firstPublished.form_key); setFormVersion(firstPublished.version); }
const firstDefinition = definitionResult.find(definition => !definition.suspended);
if (firstDefinition) setProcessKey(firstDefinition.key);
} catch (error) { setMessage(error instanceof Error ? error.message : '配置加载失败'); }
}
useEffect(() => { void load(); }, []);
useEffect(() => { if (selectedVersions.length && !selectedVersions.some(form => form.version === formVersion)) setFormVersion(selectedVersions[0].version); }, [formKey, forms]);
const ruleSummary = useMemo(() => {
const type = leaveTypes.find(item => item.value === leaveType)?.label ?? leaveType;
return `${type} · ${minDays || '0'}${maxDays || '∞'} 天 · 优先级 ${priority}`;
}, [leaveType, minDays, maxDays, priority]);
async function createBinding() {
try {
await api('/admin/process-configuration/bindings', { method: 'POST', body: JSON.stringify({
businessType: 'LEAVE_REQUEST', formKey, formVersion, processDefinitionKey: processKey,
leaveType: leaveType || null,
minDurationMinutes: minDays === '' ? null : Math.round(Number(minDays) * 480),
maxDurationMinutes: maxDays === '' ? null : Math.round(Number(maxDays) * 480), priority,
}) });
setMessage('流程绑定已启用'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : '绑定创建失败'); }
}
async function toggle(binding: Binding) {
try {
const status = binding.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
await api(`/admin/process-configuration/bindings/${binding.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
setMessage(status === 'ACTIVE' ? '绑定已启用' : '绑定已停用'); await load();
} catch (error) { setMessage(error instanceof Error ? error.message : '状态修改失败'); }
}
return <section className="bindingPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="bindingIntro"><div><h1></h1><p> Flowable </p></div><span className="safeBadge"></span></div>
<div className="bindingGrid">
<div className="configCard"><h2></h2>
<label><input value="请假申请" disabled /></label>
<div className="twoColumns"><label><select value={formKey} onChange={e => setFormKey(e.target.value)}>{[...new Set(published.map(form => form.form_key))].map(key => <option key={key}>{key}</option>)}</select></label><label><select value={formVersion} onChange={e => setFormVersion(Number(e.target.value))}>{selectedVersions.map(form => <option key={form.version} value={form.version}>v{form.version} · </option>)}</select></label></div>
<label>Flowable <select value={processKey} onChange={e => setProcessKey(e.target.value)}>{definitions.filter(definition => !definition.suspended).map(definition => <option key={definition.id} value={definition.key}>{definition.name || definition.key} · v{definition.version}</option>)}</select></label>
<label><select value={leaveType} onChange={e => setLeaveType(e.target.value)}>{leaveTypes.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
<div className="twoColumns"><label><input type="number" min="0" step="0.5" value={minDays} onChange={e => setMinDays(e.target.value)} placeholder="不限" /></label><label><input type="number" min="0" step="0.5" value={maxDays} onChange={e => setMaxDays(e.target.value)} placeholder="不限" /></label></div>
<label><input type="number" value={priority} onChange={e => setPriority(Number(e.target.value))} /></label>
<div className="rulePreview"><strong></strong><span>{ruleSummary}</span></div>
<button className="primaryWide" onClick={createBinding} disabled={!formKey || !processKey}></button>
</div>
<div className="flowCard"><h2></h2><div className="flowPreview"><div className="flowNode source"><span>LEAVE_REQUEST</span></div><i></i><div className="flowNode decision"><span> · · </span></div><i></i><div className="flowNode target"><span>{processKey || '请选择流程'}</span></div></div><div className="guardrails"><p> </p><p> </p><p> </p><p> </p></div></div>
</div>
<div className="bindingList"><h2></h2>{bindings.length === 0 ? <p className="empty"></p> : bindings.map(binding => <article key={binding.id} className={binding.status === 'ACTIVE' ? '' : 'inactive'}><div><strong>{binding.form_key} · v{binding.form_version}</strong><span>{binding.business_type} {binding.process_definition_key}</span></div><div className="conditions"><span>{binding.leave_type || '全部类型'}</span><span>{minutesToDays(binding.min_duration_minutes)} {minutesToDays(binding.max_duration_minutes)}</span><span>P{binding.priority}</span></div><button className={binding.status === 'ACTIVE' ? 'dangerGhost' : ''} onClick={() => toggle(binding)}>{binding.status === 'ACTIVE' ? '停用' : '启用'}</button></article>)}</div>
</section>;
}
+113
View File
@@ -0,0 +1,113 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from './api';
import { AiDesignerChat, type DesignerSuggestion } from './AiDesignerChat';
type Mode = 'SERIAL' | 'PARALLEL' | 'CONDITIONAL';
type Assignee = 'approverId' | 'oaAdministratorId' | 'hrReviewerId';
type Step = { id: string; name: string; assigneeVariable: Assignee };
type ApprovalRule = { variable: Assignee; label: string; sourceType: string; selector: string; scope: string; emptyPolicy: string; description: string };
type ProcessHistory = { key: string; version: number; name: string; mode: Mode; status: string; createdAt: string; template: { key: string; name: string; mode: Mode; steps: { name: string; assigneeVariable: Assignee }[]; conditionThreshold: number } };
const fallbackRules: ApprovalRule[] = [
{ variable: 'approverId', label: '发起人所在部门主管', sourceType: 'POSITION', selector: 'manager', scope: 'APPLICANT_DEPARTMENT', emptyPolicy: 'REJECT_SUBMISSION', description: '根据发起人的主任职解析部门主管。' },
{ variable: 'oaAdministratorId', label: '租户 OA 管理员', sourceType: 'ROLE', selector: 'oa_admin', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 OA 管理员。' },
{ variable: 'hrReviewerId', label: '租户 HR 复核人', sourceType: 'ROLE', selector: 'hr_reviewer', scope: 'TENANT', emptyPolicy: 'REJECT_SUBMISSION', description: '选择当前租户有效的 HR 复核人。' },
];
const modes: { value: Mode; label: string; hint: string }[] = [
{ value: 'SERIAL', label: '串行审批', hint: '依次审批,任一驳回即结束' },
{ value: 'PARALLEL', label: '并行会签', hint: '同时审批,全部通过才结束' },
{ value: 'CONDITIONAL', label: '条件审批', hint: '达到时长阈值后增加复核' },
];
export function ProcessDesigner() {
const [key, setKey] = useState('leaveApprovalCustom');
const [name, setName] = useState('自定义请假审批');
const [mode, setMode] = useState<Mode>('SERIAL');
const [thresholdDays, setThresholdDays] = useState(3);
const [steps, setSteps] = useState<Step[]>([
{ id: crypto.randomUUID(), name: '部门主管审批', assigneeVariable: 'approverId' },
{ id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' },
]);
const [selected, setSelected] = useState(steps[0].id);
const [message, setMessage] = useState('');
const [showAI, setShowAI] = useState(false);
const [rules, setRules] = useState<ApprovalRule[]>(fallbackRules);
const [history, setHistory] = useState<ProcessHistory[]>([]);
async function loadConfiguration() {
try {
const [ruleResult, historyResult] = await Promise.all([
api<ApprovalRule[]>('/admin/process-configuration/approver-rules'),
api<ProcessHistory[]>('/admin/process-configuration/processes'),
]);
setRules(ruleResult); setHistory(historyResult);
} catch (error) { setMessage(error instanceof Error ? error.message : '流程配置加载失败'); }
}
useEffect(() => { void loadConfiguration(); }, []);
const selectedStep = steps.find(step => step.id === selected);
const effectiveSteps = mode === 'CONDITIONAL' ? steps.slice(0, 2) : steps;
const summary = useMemo(() => modes.find(item => item.value === mode)?.hint, [mode]);
const duplicateRestrictedRule = mode !== 'SERIAL' && new Set(effectiveSteps.map(step => step.assigneeVariable)).size !== effectiveSteps.length;
const selectedRule = rules.find(rule => rule.variable === selectedStep?.assigneeVariable);
function applySuggestion(s: DesignerSuggestion) {
if (s.process) {
const p = s.process;
const m = (['SERIAL', 'PARALLEL', 'CONDITIONAL'].includes(p.mode) ? p.mode : 'SERIAL') as Mode;
setMode(m);
if (p.steps.length) {
const newSteps = p.steps.map(st => ({ id: crypto.randomUUID(), name: st.name, assigneeVariable: (['approverId', 'oaAdministratorId', 'hrReviewerId'].includes(st.assigneeVariable) ? st.assigneeVariable : 'approverId') as Assignee }));
setSteps(newSteps); setSelected(newSteps[0]?.id ?? '');
}
if (p.conditionThresholdDays != null) setThresholdDays(p.conditionThresholdDays);
}
}
function changeMode(next: Mode) {
setMode(next);
if (next === 'CONDITIONAL' && steps.length < 2) setSteps([...steps, { id: crypto.randomUUID(), name: 'OA 管理员复核', assigneeVariable: 'oaAdministratorId' }]);
}
function update(patch: Partial<Step>) { setSteps(steps.map(step => step.id === selected ? { ...step, ...patch } : step)); }
function addStep() {
const step: Step = { id: crypto.randomUUID(), name: `审批节点 ${steps.length + 1}`, assigneeVariable: 'approverId' };
setSteps([...steps, step]); setSelected(step.id);
}
function move(id: string, delta: number) {
const from = steps.findIndex(step => step.id === id), to = from + delta;
if (to < 0 || to >= steps.length) return;
const next = [...steps]; [next[from], next[to]] = [next[to], next[from]]; setSteps(next);
}
function loadTemplate(item: ProcessHistory) {
setKey(item.template.key); setName(item.template.name); setMode(item.template.mode);
const restored = item.template.steps.map(step => ({ id: crypto.randomUUID(), name: step.name, assigneeVariable: step.assigneeVariable }));
setSteps(restored); setSelected(restored[0]?.id ?? ''); setThresholdDays(item.template.conditionThreshold / 480);
setMessage(`${item.key} v${item.version} 已加载,可修改后部署新版本`);
}
async function deploy() {
try {
const result = await api<{ key: string; version: number }>('/admin/process-configuration/processes/deploy', { method: 'POST', body: JSON.stringify({
key, name, mode, steps: effectiveSteps.map(({ name: stepName, assigneeVariable }) => ({ name: stepName, assigneeVariable })),
conditionVariable: 'durationMinutes', conditionThreshold: Math.round(thresholdDays * 480),
}) });
setMessage(`${result.key} v${result.version} 已部署到 Flowable`);
await loadConfiguration();
} catch (error) { setMessage(error instanceof Error ? error.message : '部署失败'); }
}
return <section className="processDesignerPage">
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
<div className="processTop"><div><h1></h1><p>使 Flowable</p></div><div className="subHeaderActions"><button className="aiToggleBtn" onClick={() => setShowAI(!showAI)}> AI </button><button onClick={deploy} disabled={duplicateRestrictedRule}></button></div></div>
<section className="processMetaRow"><div className="processMeta"><label> Key<input value={key} onChange={e => setKey(e.target.value)} /></label><label><input value={name} onChange={e => setName(e.target.value)} /></label></div><div className="modeSelector">{modes.map(item => <button key={item.value} className={mode === item.value ? 'modeActive' : 'modeButton'} onClick={() => changeMode(item.value)}><strong>{item.label}</strong><span>{item.hint}</span></button>)}</div></section>
<div className={`processWorkspace ${showAI ? 'processWorkspaceWithAI' : ''}`}>
<div className="processCanvas"><div className="templateHint"><strong>{modes.find(item => item.value === mode)?.label}</strong><span>{summary}</span></div>
<div className={`processGraph ${mode.toLowerCase()}`}><ProcessNode kind="start" title="开始" subtitle="业务已提交" /><Arrow />
{mode === 'PARALLEL' && <><ProcessNode kind="gateway" title="并行拆分" subtitle="同时创建任务" /><Arrow /></>}
<div className={mode === 'PARALLEL' ? 'parallelBranches' : 'linearNodes'}>{effectiveSteps.map((step, index) => <div className="graphStep" key={step.id}><button className={`approvalNode ${selected === step.id ? 'nodeSelected' : ''}`} onClick={() => setSelected(step.id)}><span>{index + 1}</span><strong>{step.name}</strong><small>{rules.find(item => item.variable === step.assigneeVariable)?.label}</small></button>{mode !== 'PARALLEL' && index < effectiveSteps.length - 1 && <Arrow label={mode === 'CONDITIONAL' ? `>${thresholdDays}` : undefined} />}</div>)}</div>
{mode === 'PARALLEL' && <><Arrow /><ProcessNode kind="gateway" title="并行汇聚" subtitle="全部通过" /></>}<Arrow /><ProcessNode kind="end" title="结束" subtitle="批准 / 驳回" /></div>
</div>
{showAI && <div className="aiChatContainer"><AiDesignerChat onApply={applySuggestion} contextLabel="流程设计" getCurrentSchema={() => JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays })} /></div>}
{showAI && <aside className="schemaPreview"><h2> Schema</h2><pre>{JSON.stringify({ key, name, mode, steps: effectiveSteps.map(({ name: n, assigneeVariable }) => ({ name: n, assigneeVariable })), thresholdDays }, null, 2)}</pre></aside>}
<aside className="processProperties"><h2></h2>{mode === 'CONDITIONAL' && <label><input type="number" min="0" step="0.5" value={thresholdDays} onChange={e => setThresholdDays(Number(e.target.value))} /></label>}<h2></h2>{selectedStep ? <><label><input value={selectedStep.name} onChange={e => update({ name: e.target.value })} /></label><label><select value={selectedStep.assigneeVariable} onChange={e => update({ assigneeVariable: e.target.value as Assignee })}>{rules.map(item => <option key={item.variable} value={item.variable}>{item.label}</option>)}</select></label>{selectedRule && <div className="ruleDetail"><div><span></span><strong>{selectedRule.sourceType === 'POSITION' ? '岗位' : '角色'} · {selectedRule.selector}</strong></div><div><span></span><strong>{selectedRule.scope === 'TENANT' ? '当前租户' : '发起人所在部门'}</strong></div><p>{selectedRule.description}</p><small></small></div>}<div className="nodeActions"><button onClick={() => move(selectedStep.id, -1)}></button><button onClick={() => move(selectedStep.id, 1)}></button><button className="dangerGhost" disabled={effectiveSteps.length <= (mode === 'CONDITIONAL' ? 2 : 1)} onClick={() => setSteps(steps.filter(step => step.id !== selectedStep.id))}></button></div></> : <p></p>}{mode !== 'CONDITIONAL' && steps.length < 6 && <button className="primaryWide" onClick={addStep}> </button>}{duplicateRestrictedRule && <div className="validationError"></div>}<div className="securityNote"><strong></strong><p></p></div><div className="processHistory"><h2></h2>{history.length === 0 ? <p></p> : history.map(item => <button key={`${item.key}:${item.version}`} onClick={() => loadTemplate(item)}><span><strong>{item.name}</strong><small>{item.key} · v{item.version} · {item.mode}</small></span><b></b></button>)}</div></aside>
</div>
</section>;
}
function ProcessNode({ kind, title, subtitle }: { kind: string; title: string; subtitle: string }) { return <div className={`templateNode ${kind}`}><strong>{title}</strong><small>{subtitle}</small></div>; }
function Arrow({ label }: { label?: string }) { return <div className="graphArrow">{label && <span>{label}</span>}</div>; }
+30
View File
@@ -0,0 +1,30 @@
import { accessToken } from './auth';
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api/v1';
const deviceId = localStorage.getItem('aioa.admin.device') ?? crypto.randomUUID();
localStorage.setItem('aioa.admin.device', deviceId);
let registeredToken = '';
async function ensureDevice(token: string) {
if (registeredToken === token) return;
const response = await fetch(`${baseUrl}/devices/register`, {
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
body: JSON.stringify({ id: deviceId, name: navigator.userAgent.slice(0, 200), platform: 'OTHER', appVersion: 'admin-web' }),
});
if (!response.ok) throw new Error(`设备注册失败 (${response.status})`);
registeredToken = token;
}
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const token = await accessToken();
if (!token) throw new Error('登录已失效');
await ensureDevice(token);
const response = await fetch(`${baseUrl}${path}`, {
...init,
headers: { Authorization: `Bearer ${token}`, 'X-AIOA-Device-Id': deviceId, 'Content-Type': 'application/json', ...init.headers },
});
if (!response.ok) throw new Error((await response.json().catch(() => null))?.detail ?? `请求失败 (${response.status})`);
if (response.status === 204 || response.headers.get('content-length') === '0') return undefined as T;
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
}
+18
View File
@@ -0,0 +1,18 @@
import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
const issuer = import.meta.env.VITE_OIDC_ISSUER ?? 'http://localhost:8081/realms/aioa';
export const userManager = new UserManager({
authority: issuer,
client_id: 'aioa-admin-web',
redirect_uri: `${window.location.origin}/callback`,
post_logout_redirect_uri: window.location.origin,
response_type: 'code',
scope: 'openid profile email',
extraQueryParams: { ui_locales: 'zh-CN' },
userStore: new WebStorageStateStore({ store: window.sessionStorage }),
});
export async function accessToken(): Promise<string | null> {
const user = await userManager.getUser();
return user && !user.expired ? user.access_token : null;
}
+5
View File
@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import { App } from './App';
import './styles.css';
createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
+9
View File
@@ -0,0 +1,9 @@
import { describe, expect, it } from 'vitest';
import { buildSchemas } from './schema';
describe('buildSchemas', () => {
it('builds required fields and safe controls', () => {
const result = buildSchemas('请假申请', [{ id: '1', key: 'reason', label: '原因', control: 'textArea', required: true }]);
expect(result.dataSchema.required).toEqual(['reason']);
expect(result.uiSchema.sections[0].controls[0].control).toBe('textArea');
});
});
+19
View File
@@ -0,0 +1,19 @@
export type ControlType = 'text' | 'textArea' | 'select' | 'dateTime' | 'number';
export type Field = { id: string; key: string; label: string; control: ControlType; required: boolean; placeholder?: string; options?: string[]; helperText?: string };
export function buildSchemas(title: string, fields: Field[]) {
const properties: Record<string, unknown> = {};
for (const field of fields) {
properties[field.key] = field.control === 'select'
? { type: 'string', enum: field.options?.filter(Boolean) ?? [] }
: field.control === 'dateTime'
? { type: 'string', format: 'date-time' }
: field.control === 'number'
? { type: 'number' }
: { type: 'string', minLength: field.required ? 1 : 0, maxLength: field.control === 'textArea' ? 2000 : 255 };
}
return {
dataSchema: { $id: `${title.toLowerCase().replace(/\s+/g, '-')}-draft`, title, type: 'object', required: fields.filter(f => f.required).map(f => f.key), properties },
uiSchema: { description: `${title} · 由 AIOA 表单设计器生成`, sections: [{ title: '基本信息', controls: fields.map(f => ({ field: f.key, label: f.label, control: f.control, placeholder: f.placeholder, ...(f.control === 'select' ? { optionLabels: Object.fromEntries((f.options ?? []).map(v => [v, v])) } : {}) })) }] },
};
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
{
"compilerOptions": {
"target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
"strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
"resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx"
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+4
View File
@@ -0,0 +1,4 @@
{
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
"include": ["vite.config.ts"]
}
+3
View File
@@ -0,0 +1,3 @@
import { defineConfig } from 'vitest/config';
import react from '@vitejs/plugin-react';
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom' }, server: { port: 5173 } });
+4
View File
@@ -0,0 +1,4 @@
.venv
__pycache__
.pytest_cache
tests
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+12 -1
View File
@@ -1,3 +1,14 @@
# AI Service
Python 3.11+、FastAPI 和 LangGraph。首期仅处理自然语言到请假草稿的结构化转换,以及流程进度查询规划;不拥有业务写权限。
FastAPI 服务负责把自然语言转换为受约束的请假草稿建议。当前使用千问 OpenAI 兼容接口,但不拥有数据库、Flowable 或业务写权限。
```bash
cd ai-service
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
export QWEN_API_KEY='...'
uvicorn app.main:app --reload --port 8000
```
接口:`POST /v1/leave-drafts/suggest`。模型输出会经过 Pydantic 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。
+1
View File
@@ -0,0 +1 @@
"""AIOA AI service."""
+17
View File
@@ -0,0 +1,17 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
qwen_api_key: str = ""
qwen_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
qwen_model: str = "qwen-plus"
request_timeout_seconds: float = 20.0
@lru_cache
def get_settings() -> Settings:
return Settings()
+79
View File
@@ -0,0 +1,79 @@
from fastapi import Depends, FastAPI, HTTPException
from app.config import get_settings
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse, DesignerSuggestionRequest, DesignerSuggestionResponse
from app.qwen import (
QwenConfigurationError,
QwenSuggestionGateway,
QwenUpstreamError,
SuggestionGateway,
ProgressGateway,
DesignerGateway,
)
app = FastAPI(title="AIOA AI Service", version="0.1.0")
def get_gateway() -> SuggestionGateway:
return QwenSuggestionGateway(get_settings())
def get_progress_gateway() -> ProgressGateway:
return QwenSuggestionGateway(get_settings())
def get_designer_gateway() -> DesignerGateway:
return QwenSuggestionGateway(get_settings())
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@app.post("/v1/leave-drafts/suggest", response_model=LeaveDraftSuggestionResponse)
async def suggest_leave_draft(
request: LeaveDraftSuggestionRequest,
gateway: SuggestionGateway = Depends(get_gateway),
) -> LeaveDraftSuggestionResponse:
try:
suggestion = await gateway.suggest(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveDraftSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
@app.post("/v1/leave-progress/answer", response_model=LeaveProgressAnswerResponse)
async def answer_leave_progress(
request: LeaveProgressAnswerRequest,
gateway: ProgressGateway = Depends(get_progress_gateway),
) -> LeaveProgressAnswerResponse:
try:
answer = await gateway.answer_progress(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
@app.post("/v1/designer/suggest", response_model=DesignerSuggestionResponse)
async def suggest_design(
request: DesignerSuggestionRequest,
gateway: DesignerGateway = Depends(get_designer_gateway),
) -> DesignerSuggestionResponse:
try:
suggestion = await gateway.suggest_design(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return DesignerSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
+145
View File
@@ -0,0 +1,145 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class LeaveType(StrEnum):
PERSONAL = "PERSONAL"
SICK = "SICK"
ANNUAL = "ANNUAL"
class LeaveDraftSuggestionRequest(BaseModel):
text: str = Field(min_length=1, max_length=2000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
now: datetime | None = None
class LeaveDraftSuggestion(BaseModel):
type: LeaveType | None = None
startsAt: datetime | None = None
endsAt: datetime | None = None
reason: str | None = Field(default=None, max_length=2000)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def validate_time_range(self) -> "LeaveDraftSuggestion":
if self.startsAt is not None and self.endsAt is not None:
if self.startsAt.tzinfo is None or self.endsAt.tzinfo is None:
raise ValueError("startsAt and endsAt must include timezone offsets")
if self.endsAt <= self.startsAt:
raise ValueError("endsAt must be later than startsAt")
if self.reason is not None:
normalized = self.reason.strip()
self.reason = normalized or None
return self
class LeaveDraftSuggestionResponse(BaseModel):
suggestion: LeaveDraftSuggestion
model: str
requiresUserConfirmation: bool = True
class LeaveProgressContext(BaseModel):
model_config = ConfigDict(extra="forbid")
requestId: str
type: LeaveType
status: str
startsAt: datetime
endsAt: datetime
activeTaskNames: list[str] = Field(default_factory=list, max_length=20)
completedTaskNames: list[str] = Field(default_factory=list, max_length=50)
processEnded: bool
timelineEventTypes: list[str] = Field(default_factory=list, max_length=50)
class LeaveProgressAnswerRequest(BaseModel):
question: str = Field(min_length=1, max_length=2000)
timezone: str = Field(min_length=1, max_length=64)
context: LeaveProgressContext
class LeaveProgressAnswerResponse(BaseModel):
answer: str = Field(min_length=1, max_length=2000)
model: str
# ── AI 对话式设计助手(多阶段交互) ──────────────────────────────────
class DesignerStage(StrEnum):
UNDERSTANDING = "UNDERSTANDING"
CLARIFYING = "CLARIFYING"
GENERATING = "GENERATING"
VALIDATING = "VALIDATING"
CONFIRMING = "CONFIRMING"
class DesignerFieldType(StrEnum):
TEXT = "text"
TEXT_AREA = "textArea"
SELECT = "select"
DATE_TIME = "dateTime"
NUMBER = "number"
class DesignerFormField(BaseModel):
key: str = Field(min_length=1, max_length=64)
label: str = Field(min_length=1, max_length=100)
control: DesignerFieldType
required: bool = False
placeholder: str | None = Field(default=None, max_length=200)
options: list[str] | None = Field(default=None, max_length=20)
helperText: str | None = Field(default=None, max_length=200)
class DesignerProcessStep(BaseModel):
name: str = Field(min_length=1, max_length=100)
assigneeVariable: str = Field(min_length=1, max_length=64)
class DesignerProcessSuggestion(BaseModel):
mode: str = Field(default="SERIAL", max_length=20)
steps: list[DesignerProcessStep] = Field(default_factory=list, max_length=10)
conditionThresholdDays: float | None = None
class SchemaValidationIssue(BaseModel):
field: str = Field(min_length=1, max_length=100)
issue: str = Field(min_length=1, max_length=300)
severity: str = Field(default="WARNING", max_length=20)
class DesignerSuggestion(BaseModel):
stage: DesignerStage = DesignerStage.UNDERSTANDING
formTitle: str | None = Field(default=None, max_length=100)
formKey: str | None = Field(default=None, max_length=64)
fields: list[DesignerFormField] = Field(default_factory=list, max_length=30)
process: DesignerProcessSuggestion | None = None
summary: str = Field(default="", max_length=500)
understanding: str = Field(default="", max_length=800)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
validationIssues: list[SchemaValidationIssue] = Field(default_factory=list, max_length=20)
schemaReady: bool = False
class ChatTurn(BaseModel):
role: str = Field(min_length=1, max_length=20)
content: str = Field(min_length=1, max_length=4000)
class DesignerSuggestionRequest(BaseModel):
message: str = Field(min_length=1, max_length=2000)
history: list[ChatTurn] = Field(default_factory=list, max_length=20)
currentSchema: str | None = Field(default=None, max_length=8000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
class DesignerSuggestionResponse(BaseModel):
suggestion: DesignerSuggestion
model: str
requiresUserConfirmation: bool = True
+203
View File
@@ -0,0 +1,203 @@
import json
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import Settings
from app.models import (
LeaveDraftSuggestion,
LeaveDraftSuggestionRequest,
LeaveProgressAnswerRequest,
DesignerSuggestionRequest,
DesignerSuggestion,
ChatTurn,
)
class SuggestionGateway(Protocol):
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion: ...
class ProgressGateway(Protocol):
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
class DesignerGateway(Protocol):
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion: ...
class QwenSuggestionGateway:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
reference_time = request.now or datetime.now(timezone.utc)
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(
{
"text": request.text,
"timezone": request.timezone,
"referenceTime": reference_time.isoformat(),
},
ensure_ascii=False,
),
},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return LeaveDraftSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid structured response") from exc
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": PROGRESS_SYSTEM_PROMPT},
{"role": "user", "content": request.model_dump_json()},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(f"{self.settings.qwen_base_url}/chat/completions", headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"}, json=payload)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
answer = json.loads(content)["answer"]
if not isinstance(answer, str) or not answer.strip() or len(answer) > 2000:
raise ValueError("invalid answer")
return answer.strip()
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
async def suggest_design(self, request: DesignerSuggestionRequest) -> DesignerSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
user_content = json.dumps(
{
"message": request.message,
"history": [t.model_dump() for t in request.history],
"currentSchema": request.currentSchema,
"timezone": request.timezone,
},
ensure_ascii=False,
)
messages: list[dict[str, str]] = [{"role": "system", "content": DESIGNER_SYSTEM_PROMPT}]
for turn in request.history:
messages.append({"role": turn.role, "content": turn.content})
messages.append({"role": "user", "content": user_content})
payload = {
"model": self.settings.qwen_model,
"temperature": 0.2,
"response_format": {"type": "json_object"},
"messages": messages,
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return DesignerSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid designer response") from exc
SYSTEM_PROMPT = """
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
输出必须是一个 JSON 对象,只允许字段:type、startsAt、endsAt、reason、assumptions、needsClarification。
type 只能是 PERSONAL、SICK、ANNUAL 或 null。时间必须是带时区偏移的 ISO-8601。
不能确定的值输出 null,并把需要用户补充的问题写入 needsClarification。
不得输出申请人、审批人、租户、权限、流程或隐藏字段。不得使用 Markdown。
""".strip()
PROGRESS_SYSTEM_PROMPT = """
你是企业 OA 只读流程进度助手。数据库记录已经由业务后端鉴权并选定,你只能依据输入 context 回答 question。
输出必须是 JSON 对象且只包含 answer 字段。回答必须明确当前 status;有 activeTaskNames 时说明当前节点;流程结束时说明已结束。
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
""".strip()
DESIGNER_SYSTEM_PROMPT = """
你是企业 OA 表单与流程设计助手,通过多阶段对话帮助用户生成正确的表单和审批流程 Schema。
## 工作流程(5 个阶段)
1. UNDERSTANDING(理解):分析用户需求,复述你的理解,判断信息是否充分。
- 如果信息不足需要澄清 → 进入 CLARIFYING
- 如果信息充分 → 直接进入 GENERATING
2. CLARIFYING(澄清):向用户提出具体问题,每次最多 3 个问题。
- 用户回答后重新评估,信息充分则进入 GENERATING
3. GENERATING(生成):根据理解生成完整的表单字段和审批流程 Schema。
- 生成后自动进入 VALIDATING
4. VALIDATING(校验):自检生成的 Schema 是否正确完整。
- 检查项:字段 key 唯一、必填字段合理、控件类型匹配、审批步骤 ≥ 2、assigneeVariable 不重复(PARALLEL/CONDITIONAL)、select 有 options
- 有问题则修复后重新校验,无问题则进入 CONFIRMING
5. CONFIRMING(确认):展示最终 Schema 摘要,请用户确认或调整。
- 用户确认 → schemaReady = true
- 用户要求调整 → 回到 GENERATING
## 输出格式
输出必须是 JSON 对象,包含以下字段:
- stage:当前阶段(UNDERSTANDING/CLARIFYING/GENERATING/VALIDATING/CONFIRMING
- understanding:你对用户需求的理解复述(中文)
- formTitle:表单中文标题
- formKey:英文 kebab-case 标识符
- fields:数组,每项含 key(英文 camelCase)、label(中文)、controltext/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
- process:对象,含 modeSERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL
- summary:一句话总结
- assumptions:你做出的假设
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
- validationIssues:校验发现的问题数组,每项含 field、issue、severityERROR/WARNING
- schemaReadySchema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true
## 约束
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorIdOA 管理员)、hrReviewerIdHR 复核人)
- 字段 key 用英文 camelCase 且唯一,label 用中文
- 审批流程至少 2 个步骤
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
- CONDITIONAL 模式需设置 conditionThresholdDays
- 不得输出权限、租户、隐藏字段等敏感信息
- 不得使用 Markdown
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
""".strip()
class QwenConfigurationError(RuntimeError):
pass
class QwenUpstreamError(RuntimeError):
pass
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "aioa-ai-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.116.1",
"httpx==0.28.1",
"pydantic-settings==2.10.1",
"uvicorn[standard]==0.35.0",
]
[project.optional-dependencies]
dev = [
"pytest==8.4.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+57
View File
@@ -0,0 +1,57 @@
from fastapi.testclient import TestClient
from app.main import app, get_progress_gateway
from app.models import LeaveProgressAnswerRequest
class FakeProgressGateway:
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
assert request.context.requestId == "leave-1"
assert request.context.activeTaskNames == ["主管审批"]
return "当前状态为审批中,正在等待主管审批。"
def test_answers_only_from_authorized_structured_context() -> None:
app.dependency_overrides[get_progress_gateway] = lambda: FakeProgressGateway()
try:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "我的请假到哪一步了?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1",
"type": "ANNUAL",
"status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z",
"endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": ["主管审批"],
"completedTaskNames": [],
"processEnded": False,
"timelineEventTypes": ["LEAVE_REQUEST_SUBMITTED"],
},
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"answer": "当前状态为审批中,正在等待主管审批。", "model": "qwen-plus"}
assert "processVariables" not in response.text
def test_rejects_unknown_context_fields() -> None:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "进度?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1", "type": "ANNUAL", "status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z", "endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": [], "completedTaskNames": [], "processEnded": False,
"timelineEventTypes": [], "processVariables": {"approverId": "secret"},
},
},
)
assert response.status_code == 422
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from fastapi.testclient import TestClient
from app.main import app, get_gateway
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveType
class FakeGateway:
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
assert request.text == "明天下午请事假四小时"
return LeaveDraftSuggestion(
type=LeaveType.PERSONAL,
startsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
reason="办理个人事务",
assumptions=["下午按 13:30 开始计算"],
)
def test_returns_structured_suggestion_without_executing_business_action() -> None:
app.dependency_overrides[get_gateway] = lambda: FakeGateway()
try:
response = TestClient(app).post(
"/v1/leave-drafts/suggest",
json={"text": "明天下午请事假四小时", "timezone": "Asia/Shanghai"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["suggestion"]["type"] == "PERSONAL"
assert body["requiresUserConfirmation"] is True
assert "applicantId" not in body["suggestion"]
def test_rejects_invalid_time_range_from_model() -> None:
try:
LeaveDraftSuggestion(
startsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
)
assert False, "validation should fail"
except ValueError:
pass
+3
View File
@@ -28,11 +28,14 @@ dependencies {
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
implementation("io.minio:minio:8.5.17")
implementation("com.google.firebase:firebase-admin:9.4.3")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("com.h2database:h2")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
}
@@ -2,8 +2,10 @@ package com.all8ai.aioa
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
class AioaApplication
fun main(args: Array<String>) {
@@ -0,0 +1,21 @@
package com.all8ai.aioa.admin.configuration
data class ApprovalRuleOption(
val variable:String,val label:String,val sourceType:String,val selector:String,
val scope:String,val emptyPolicy:String,val description:String,
)
fun approvalRuleCatalog()=listOf(
ApprovalRuleOption(
"approverId","发起人所在部门主管","POSITION","manager","APPLICANT_DEPARTMENT","REJECT_SUBMISSION",
"根据发起人的有效主任职定位部门,再选择该部门有效的 manager 岗位人员。",
),
ApprovalRuleOption(
"oaAdministratorId","租户 OA 管理员","ROLE","oa_admin","TENANT","REJECT_SUBMISSION",
"在当前租户内选择拥有有效 oa_admin 角色且账号状态正常的人员。",
),
ApprovalRuleOption(
"hrReviewerId","租户 HR 复核人","ROLE","hr_reviewer","TENANT","REJECT_SUBMISSION",
"在当前租户内选择拥有有效 hr_reviewer 角色且账号状态正常的人员。",
),
)
@@ -0,0 +1,74 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.flowable.engine.RepositoryService
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.transaction.annotation.Transactional
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/process-configuration")
class ProcessConfigurationController(
private val users:CurrentUserService,private val dsl:DSLContext,private val mapper:ObjectMapper,
private val flowable:RepositoryService,private val audit:AuditService,
) {
private fun actor(jwt:Jwt)=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT) }
@GetMapping("/forms") fun forms(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT form_key,version,status,created_at,published_at FROM form.definition WHERE tenant_id=? ORDER BY form_key,version DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/bindings") fun bindings(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> { val a=actor(jwt);return dsl.fetch("SELECT id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status FROM workflow.process_binding WHERE tenant_id=? ORDER BY priority DESC",a.tenantId).map{it.intoMap()} }
@GetMapping("/approver-rules") fun approverRules(@AuthenticationPrincipal jwt:Jwt):List<ApprovalRuleOption> { actor(jwt);return approvalRuleCatalog() }
@GetMapping("/processes") fun processes(@AuthenticationPrincipal jwt:Jwt):List<Map<String,Any?>> {
val a=actor(jwt)
return dsl.fetch("SELECT process_definition_key,version,process_definition_id,deployment_id,name,mode,status,template_spec::text template_spec,created_at FROM workflow.process_template WHERE tenant_id=? ORDER BY created_at DESC",a.tenantId).map { record ->
mapOf(
"key" to record.get("process_definition_key",String::class.java),"version" to record.get("version",Int::class.java),
"processDefinitionId" to record.get("process_definition_id",String::class.java),"deploymentId" to record.get("deployment_id",String::class.java),
"name" to record.get("name",String::class.java),"mode" to record.get("mode",String::class.java),"status" to record.get("status",String::class.java),
"template" to mapper.readValue(record.get("template_spec",String::class.java),Map::class.java),"createdAt" to record.get("created_at"),
)
}
}
@PostMapping("/forms") @Transactional fun createForm(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:FormVersionCommand):Map<String,Any?> {
val a=actor(jwt);validateSchema(body.dataSchema,body.uiSchema);val version=(dsl.fetchOne("SELECT COALESCE(MAX(version),0)+1 v FROM form.definition WHERE tenant_id=? AND form_key=?",a.tenantId,body.formKey)?.get("v",Int::class.java)?:1)
dsl.execute("INSERT INTO form.definition(tenant_id,form_key,version,status,data_schema,ui_schema) VALUES(?,?,?,'DRAFT',CAST(? AS JSONB),CAST(? AS JSONB))",a.tenantId,body.formKey,version,mapper.writeValueAsString(body.dataSchema),mapper.writeValueAsString(body.uiSchema))
audit.recordSuccess(a,"FORM_VERSION_CREATED","FORM_DEFINITION","${body.formKey}:$version",null,mapOf("version" to version));return mapOf("formKey" to body.formKey,"version" to version,"status" to "DRAFT")
}
@PostMapping("/forms/{key}/{version}/publish") @Transactional fun publish(@AuthenticationPrincipal jwt:Jwt,@PathVariable key:String,@PathVariable version:Int) {
val a=actor(jwt);dsl.execute("UPDATE form.definition SET status='RETIRED' WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",a.tenantId,key)
if(dsl.execute("UPDATE form.definition SET status='PUBLISHED',published_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND form_key=? AND version=? AND status='DRAFT'",a.tenantId,key,version)!=1) throw ApiException(HttpStatus.CONFLICT,"FORM_VERSION_NOT_DRAFT","表单版本不存在或不可发布")
audit.recordSuccess(a,"FORM_VERSION_PUBLISHED","FORM_DEFINITION","$key:$version",null,mapOf("version" to version))
}
@PostMapping("/bindings") @Transactional fun createBinding(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:BindingCommand):Map<String,Any?> {
val a=actor(jwt)
if(!isValidDurationRange(body.minDurationMinutes,body.maxDurationMinutes)) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_BINDING_RANGE_INVALID","时长范围无效")
val publishedFormExists=dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM form.definition WHERE tenant_id=? AND form_key=? AND version=? AND status='PUBLISHED') found",a.tenantId,body.formKey,body.formVersion)?.get("found",Boolean::class.java)==true
if(!publishedFormExists) throw ApiException(HttpStatus.BAD_REQUEST,"PUBLISHED_FORM_NOT_FOUND","已发布表单版本不存在")
if(flowable.createProcessDefinitionQuery().processDefinitionKey(body.processDefinitionKey).latestVersion().singleResult()==null) throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_DEFINITION_NOT_FOUND","流程定义不存在")
val id=UuidV7.generate();dsl.execute("INSERT INTO workflow.process_binding(id,tenant_id,business_type,form_key,form_version,process_definition_key,leave_type,min_duration_minutes,max_duration_minutes,priority,status) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE')",id,a.tenantId,body.businessType,body.formKey,body.formVersion,body.processDefinitionKey,body.leaveType,body.minDurationMinutes,body.maxDurationMinutes,body.priority)
audit.recordSuccess(a,"PROCESS_BINDING_CREATED","PROCESS_BINDING",id.toString(),null,mapOf("processDefinitionKey" to body.processDefinitionKey,"formVersion" to body.formVersion));return mapOf("id" to id,"status" to "ACTIVE")
}
@PutMapping("/bindings/{id}/status") fun status(@AuthenticationPrincipal jwt:Jwt,@PathVariable id:UUID,@RequestBody body:BindingStatusCommand) { val a=actor(jwt);if(body.status !in setOf("ACTIVE","INACTIVE")) throw ApiException(HttpStatus.BAD_REQUEST,"BINDING_STATUS_INVALID","状态无效");if(dsl.execute("UPDATE workflow.process_binding SET status=?,updated_at=CURRENT_TIMESTAMP WHERE tenant_id=? AND id=?",body.status,a.tenantId,id)!=1) throw ApiException(HttpStatus.NOT_FOUND,"PROCESS_BINDING_NOT_FOUND","绑定不存在");audit.recordSuccess(a,"PROCESS_BINDING_STATUS_CHANGED","PROCESS_BINDING",id.toString(),null,mapOf("status" to body.status)) }
@PostMapping("/processes/deploy") @Transactional fun deploy(@AuthenticationPrincipal jwt:Jwt,@RequestBody body:ProcessTemplateCommand):Map<String,Any?> {
val a=actor(jwt);val xml=generateProcessTemplate(body)
val deployment=flowable.createDeployment().name("AIOA Designer: ${body.name}").addString("${body.key}.bpmn20.xml",xml).deploy()
val definition=flowable.createProcessDefinitionQuery().deploymentId(deployment.id).singleResult()
?: throw ApiException(HttpStatus.INTERNAL_SERVER_ERROR,"PROCESS_DEPLOYMENT_FAILED","流程部署失败")
dsl.execute("INSERT INTO workflow.process_template(tenant_id,process_definition_key,version,process_definition_id,deployment_id,name,mode,template_spec,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSONB),?)",a.tenantId,definition.key,definition.version,definition.id,deployment.id,body.name,body.mode,mapper.writeValueAsString(body),a.id)
audit.recordSuccess(a,"PROCESS_DEFINITION_DEPLOYED","PROCESS_DEFINITION",definition.id,null,mapOf("key" to definition.key,"version" to definition.version,"mode" to body.mode))
return mapOf("id" to definition.id,"key" to definition.key,"version" to definition.version,"deploymentId" to deployment.id)
}
private fun validateSchema(data:Map<String,Any>,ui:Map<String,Any>){if(data["type"]!="object"||data["properties"] !is Map<*,*>||ui["sections"] !is List<*>) throw ApiException(HttpStatus.BAD_REQUEST,"FORM_SCHEMA_INVALID","表单 Schema 结构无效")}
}
data class FormVersionCommand(val formKey:String,val dataSchema:Map<String,Any>,val uiSchema:Map<String,Any>)
data class BindingCommand(val businessType:String,val formKey:String,val formVersion:Int,val processDefinitionKey:String,val leaveType:String?=null,val minDurationMinutes:Long?=null,val maxDurationMinutes:Long?=null,val priority:Int=100)
data class BindingStatusCommand(val status:String)
internal fun isValidDurationRange(min:Long?,max:Long?):Boolean = min?.let { it>=0 } != false && max?.let { it>=0 } != false && (min==null || max==null || min<=max)
@@ -0,0 +1,83 @@
package com.all8ai.aioa.admin.configuration
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
data class ProcessTemplateCommand(
val key:String,val name:String,val mode:String,val steps:List<ApprovalStepCommand>,
val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
)
private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
private val allowedConditions=setOf("durationMinutes")
fun generateProcessTemplate(command:ProcessTemplateCommand):String {
if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
if(command.mode in setOf("PARALLEL","CONDITIONAL") && command.steps.map{it.assigneeVariable}.distinct().size!=command.steps.size) invalid("并行或条件复核节点必须使用不同的审批人规则")
if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
val body=when(command.mode){
"SERIAL"->serial(command.steps)
"PARALLEL"->parallel(command.steps)
else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
}
return """<?xml version="1.0" encoding="UTF-8"?>
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/designer">
<process id="${command.key}" name="${xml(command.name)}" isExecutable="true">
$body
</process>
</definitions>"""
}
private fun serial(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
steps.forEachIndexed { index,step ->
appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
}
appendEnds()
}.trimEnd()
private fun parallel(steps:List<ApprovalStepCommand>):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-split\" sourceRef=\"start\" targetRef=\"parallelSplit\"/>")
appendLine(" <parallelGateway id=\"parallelSplit\" name=\"并行会签\"/>")
steps.forEachIndexed { index,step ->
appendLine(" <sequenceFlow id=\"flow-split-task$index\" sourceRef=\"parallelSplit\" targetRef=\"task$index\"/>")
appendDecisionTask(index,step,"parallelJoin")
}
appendLine(" <parallelGateway id=\"parallelJoin\" name=\"全部通过\"/>")
appendLine(" <sequenceFlow id=\"flow-join-end\" sourceRef=\"parallelJoin\" targetRef=\"approvedEnd\"/>")
appendEnds()
}.trimEnd()
private fun conditional(steps:List<ApprovalStepCommand>,variable:String,threshold:Long):String=buildString {
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
appendDecisionTask(0,steps[0],"routeDecision")
appendLine(" <exclusiveGateway id=\"routeDecision\" name=\"条件路由\"/>")
appendLine(" <sequenceFlow id=\"flow-condition-short\" sourceRef=\"routeDecision\" targetRef=\"approvedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable <= $threshold}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-condition-long\" sourceRef=\"routeDecision\" targetRef=\"task1\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable > $threshold}]]></conditionExpression></sequenceFlow>")
appendDecisionTask(1,steps[1],"approvedEnd")
appendEnds()
}.trimEnd()
private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
appendLine(" <userTask id=\"task$index\" name=\"${xml(step.name)}\" flowable:assignee=\"\${${step.assigneeVariable}}\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-decision\" sourceRef=\"task$index\" targetRef=\"decision$index\"/>")
appendLine(" <exclusiveGateway id=\"decision$index\" name=\"审批结果\"/>")
appendLine(" <sequenceFlow id=\"flow-task$index-approved\" sourceRef=\"decision$index\" targetRef=\"$approvedTarget\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == true}]]></conditionExpression></sequenceFlow>")
appendLine(" <sequenceFlow id=\"flow-task$index-rejected\" sourceRef=\"decision$index\" targetRef=\"rejectedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == false}]]></conditionExpression></sequenceFlow>")
}
private fun StringBuilder.appendEnds(){
appendLine(" <endEvent id=\"approvedEnd\" name=\"已批准\"/>")
appendLine(" <endEvent id=\"rejectedEnd\" name=\"已驳回\"><terminateEventDefinition/></endEvent>")
}
private fun xml(value:String)=value.replace("&","&amp;").replace("<","&lt;").replace(">","&gt;").replace("\"","&quot;")
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
@@ -0,0 +1,35 @@
package com.all8ai.aioa.admin.metrics
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import org.jooq.DSLContext
import org.flowable.engine.RuntimeService
import org.flowable.engine.TaskService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
data class OperationsMetrics(
val leaveByStatus:Map<String,Int>,val activeWorkflowInstances:Long,val activeWorkflowTasks:Long,
val unreadNotifications:Int,val pendingPush:Int,val failedPushAttempts:Int,val activeDevices:Int,
)
@RestController @RequestMapping("/api/v1/admin/metrics")
class OperationsMetricsController(private val users:CurrentUserService,private val dsl:DSLContext,private val runtime:RuntimeService,private val tasks:TaskService) {
@GetMapping fun metrics(@AuthenticationPrincipal jwt:Jwt):OperationsMetrics {
val actor=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")); actor.requirePermission(ToolPermission.OPERATIONS_METRICS_READ_TENANT)
fun count(sql:String,vararg bindings:Any):Int=dsl.fetchOne(sql,*bindings)?.get("count",Int::class.java)?:0
val statuses=dsl.fetch("SELECT status,COUNT(*)::int count FROM business.leave_request WHERE tenant_id=? GROUP BY status",actor.tenantId)
.associate { it.get("status",String::class.java)!! to it.get("count",Int::class.java)!! }
return OperationsMetrics(statuses,
runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).count(),
tasks.createTaskQuery().processVariableValueEquals("tenantId",actor.tenantId.toString()).active().count(),
count("SELECT COUNT(*)::int count FROM communication.notification WHERE tenant_id=? AND read_at IS NULL",actor.tenantId),
count("SELECT COUNT(*)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=? AND o.status='PENDING'",actor.tenantId),
count("SELECT COALESCE(SUM(o.attempts),0)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=?",actor.tenantId),
count("SELECT COUNT(*)::int count FROM identity.user_device WHERE tenant_id=? AND status='ACTIVE'",actor.tenantId))
}
}
@@ -0,0 +1,77 @@
package com.all8ai.aioa.admin.organization
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.util.UUID
data class AdminDepartment(val id: UUID, val code: String, val name: String, val status: String)
data class AdminRole(val id: UUID, val code: String, val name: String, val status: String)
data class AdminUser(val id: UUID, val username: String, val displayName: String, val email: String?, val status: String, val departmentId: UUID?, val positionId: UUID?, val roles: Set<String>)
@Service
class AdminOrganizationService(private val dsl: DSLContext, private val audit: AuditService) {
fun departments(actor: CurrentUser): List<AdminDepartment> {
require(actor)
return dsl.fetch("SELECT id, code, name, status FROM organization.department WHERE tenant_id = ? ORDER BY code", actor.tenantId)
.map { AdminDepartment(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
}
fun roles(actor: CurrentUser): List<AdminRole> {
require(actor)
return dsl.fetch("SELECT id, code, name, status FROM authz.role WHERE tenant_id = ? ORDER BY code", actor.tenantId)
.map { AdminRole(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
}
fun users(actor: CurrentUser): List<AdminUser> {
require(actor)
return dsl.fetch("""
SELECT u.id, u.username, u.display_name, u.email, u.status, a.department_id, a.position_id,
COALESCE(array_agg(r.code ORDER BY r.code) FILTER (WHERE r.code IS NOT NULL), '{}') AS roles
FROM identity.user_account u
LEFT JOIN organization.user_assignment a ON a.tenant_id=u.tenant_id AND a.user_id=u.id AND a.is_primary=TRUE AND a.effective_until IS NULL
LEFT JOIN authz.user_role ur ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id AND ur.effective_until IS NULL
LEFT JOIN authz.role r ON r.tenant_id=ur.tenant_id AND r.id=ur.role_id
WHERE u.tenant_id=? GROUP BY u.id,a.department_id,a.position_id ORDER BY u.username
""".trimIndent(), actor.tenantId).map {
AdminUser(it.get("id", UUID::class.java)!!, it.get("username", String::class.java)!!, it.get("display_name", String::class.java)!!,
it.get("email", String::class.java), it.get("status", String::class.java)!!, it.get("department_id", UUID::class.java),
it.get("position_id", UUID::class.java), (it.get("roles") as? Array<*>)?.filterIsInstance<String>()?.toSet().orEmpty())
}
}
@Transactional
fun createDepartment(actor: CurrentUser, code: String, name: String): AdminDepartment {
require(actor); val c = valid(code, 64); val n = valid(name, 200); val id = UuidV7.generate()
try { dsl.execute("INSERT INTO organization.department(id,tenant_id,code,name,status) VALUES(?,?,?,?,'ACTIVE')", id, actor.tenantId, c, n) }
catch (_: Exception) { throw ApiException(HttpStatus.CONFLICT, "DEPARTMENT_CODE_EXISTS", "部门编码已存在") }
audit.recordSuccess(actor, "DEPARTMENT_CREATED", "DEPARTMENT", id.toString(), null, mapOf("code" to c))
return AdminDepartment(id, c, n, "ACTIVE")
}
@Transactional
fun replaceRoles(actor: CurrentUser, userId: UUID, roleCodes: Set<String>) {
require(actor); if (roleCodes.isEmpty()) throw ApiException(HttpStatus.BAD_REQUEST, "ROLES_REQUIRED", "至少保留一个角色")
val bindings = mutableListOf<Any>(actor.tenantId).apply { addAll(roleCodes) }.toTypedArray()
val roleIds = dsl.fetch("SELECT id,code FROM authz.role WHERE tenant_id=? AND code IN (${roleCodes.joinToString { "?" }}) AND status='ACTIVE'", *bindings)
if (roleIds.size != roleCodes.size) throw ApiException(HttpStatus.BAD_REQUEST, "ROLE_INVALID", "包含不存在的角色")
if (dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM identity.user_account WHERE tenant_id=? AND id=?) AS ok", actor.tenantId, userId)?.get("ok", Boolean::class.java) != true) throw ApiException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "用户不存在")
dsl.execute("DELETE FROM authz.user_role WHERE tenant_id=? AND user_id=?", actor.tenantId, userId)
roleIds.forEach { dsl.execute("INSERT INTO authz.user_role(tenant_id,user_id,role_id) VALUES(?,?,?)", actor.tenantId, userId, it.get("id", UUID::class.java)) }
audit.recordSuccess(actor, "USER_ROLES_REPLACED", "USER", userId.toString(), null, mapOf("roles" to roleCodes.sorted()))
}
@Transactional
fun assign(actor: CurrentUser, userId: UUID, departmentId: UUID, positionId: UUID) {
require(actor)
val validRefs = dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM organization.department WHERE tenant_id=? AND id=? AND status='ACTIVE') AND EXISTS(SELECT 1 FROM organization.position WHERE tenant_id=? AND id=? AND status='ACTIVE') AS ok", actor.tenantId, departmentId, actor.tenantId, positionId)?.get("ok", Boolean::class.java) == true
if (!validRefs) throw ApiException(HttpStatus.BAD_REQUEST, "ASSIGNMENT_REFERENCE_INVALID", "部门或岗位无效")
dsl.execute("UPDATE organization.user_assignment SET effective_until=CURRENT_TIMESTAMP,is_primary=FALSE WHERE tenant_id=? AND user_id=? AND is_primary=TRUE AND effective_until IS NULL", actor.tenantId, userId)
dsl.execute("INSERT INTO organization.user_assignment(id,tenant_id,user_id,department_id,position_id,is_primary) VALUES(?,?,?,?,?,TRUE)", UuidV7.generate(), actor.tenantId, userId, departmentId, positionId)
audit.recordSuccess(actor, "USER_ASSIGNMENT_REPLACED", "USER", userId.toString(), null, mapOf("departmentId" to departmentId, "positionId" to positionId))
}
private fun require(actor: CurrentUser) = actor.requirePermission(ToolPermission.ORGANIZATION_MANAGE_TENANT)
private fun valid(value: String, max: Int) = value.trim().takeIf { it.isNotEmpty() && it.length <= max && it.matches(Regex("[A-Za-z0-9._-]+|[\\p{L}0-9 ._-]+")) } ?: throw ApiException(HttpStatus.BAD_REQUEST, "VALUE_INVALID", "输入值无效")
}
@@ -0,0 +1,24 @@
package com.all8ai.aioa.admin.organization
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController @RequestMapping("/api/v1/admin/organization")
class AdminOrganizationController(private val users: CurrentUserService, private val service: AdminOrganizationService) {
private fun actor(jwt: Jwt) = users.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
@GetMapping("/departments") fun departments(@AuthenticationPrincipal jwt: Jwt) = service.departments(actor(jwt))
@PostMapping("/departments") fun createDepartment(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody body: DepartmentCommand) = service.createDepartment(actor(jwt), body.code, body.name)
@GetMapping("/roles") fun roles(@AuthenticationPrincipal jwt: Jwt) = service.roles(actor(jwt))
@GetMapping("/users") fun listUsers(@AuthenticationPrincipal jwt: Jwt) = service.users(actor(jwt))
@PutMapping("/users/{id}/roles") fun roles(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: RolesCommand) = service.replaceRoles(actor(jwt), id, body.roles)
@PutMapping("/users/{id}/assignment") fun assign(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: AssignmentCommand) = service.assign(actor(jwt), id, body.departmentId, body.positionId)
}
data class DepartmentCommand(@field:NotBlank @field:Size(max=64) val code:String,@field:NotBlank @field:Size(max=200) val name:String)
data class RolesCommand(val roles:Set<String>)
data class AssignmentCommand(val departmentId:UUID,val positionId:UUID)
@@ -0,0 +1,36 @@
package com.all8ai.aioa.admin.workflow
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import org.flowable.engine.HistoryService
import org.flowable.engine.RepositoryService
import org.flowable.engine.RuntimeService
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.time.Instant
data class ProcessDefinitionView(val id:String,val key:String,val name:String?,val version:Int,val deploymentId:String,val suspended:Boolean)
data class ProcessInstanceView(val id:String,val definitionId:String,val businessKey:String?,val startedAt:Instant?,val endedAt:Instant?,val active:Boolean)
@RestController @RequestMapping("/api/v1/admin/workflows")
class AdminWorkflowController(
private val currentUsers:CurrentUserService, private val repository:RepositoryService,
private val runtime:RuntimeService, private val history:HistoryService,
) {
private fun actor(jwt:Jwt):CurrentUser=currentUsers.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.WORKFLOW_READ_TENANT) }
@GetMapping("/definitions") fun definitions(@AuthenticationPrincipal jwt:Jwt):List<ProcessDefinitionView> {
actor(jwt); return repository.createProcessDefinitionQuery().orderByProcessDefinitionKey().asc().orderByProcessDefinitionVersion().desc().list()
.map { ProcessDefinitionView(it.id,it.key,it.name,it.version,it.deploymentId,it.isSuspended) }
}
@GetMapping("/instances") fun instances(@AuthenticationPrincipal jwt:Jwt,@RequestParam(defaultValue="100") limit:Int):List<ProcessInstanceView> {
val actor=actor(jwt); val active=runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).listPage(0,limit.coerceIn(1,200))
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,null,null,true) }
if(active.size>=limit) return active
val ended=history.createHistoricProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).finished().orderByProcessInstanceEndTime().desc().listPage(0,(limit-active.size).coerceIn(0,200))
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,it.startTime?.toInstant(),it.endTime?.toInstant(),false) }
return active+ended
}
}
@@ -0,0 +1,43 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiDesignerService
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/ai/designer-suggestions")
class AiDesignerController(
private val currentUserService: CurrentUserService,
private val service: AiDesignerService,
) {
@PostMapping
fun suggest(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody request: AiDesignerSuggestionRequest,
): DesignerSuggestionResult = service.suggest(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
request.message,
request.history?.map { ChatTurn(it.role, it.content) } ?: emptyList(),
request.currentSchema,
request.timezone,
)
}
data class AiDesignerSuggestionRequest(
@field:NotBlank @field:Size(max = 2000) val message: String,
val history: List<ChatTurnDto>? = null,
@field:Size(max = 8000) val currentSchema: String? = null,
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
)
data class ChatTurnDto(val role: String, val content: String)
@@ -0,0 +1,36 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiLeaveDraftService
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/ai/leave-draft-suggestions")
class AiLeaveDraftController(
private val currentUserService: CurrentUserService,
private val service: AiLeaveDraftService,
) {
@PostMapping
fun suggest(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody request: AiLeaveDraftSuggestionRequest,
): SuggestedLeaveDraft = service.suggest(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
request.text,
request.timezone,
)
}
data class AiLeaveDraftSuggestionRequest(
@field:NotBlank @field:Size(max = 2000) val text: String,
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
)
@@ -0,0 +1,20 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiLeaveProgressService
import com.all8ai.aioa.ai.domain.LeaveProgressAnswerResult
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/ai/leave-progress-answers")
class AiLeaveProgressController(private val currentUserService: CurrentUserService, private val service: AiLeaveProgressService) {
@PostMapping fun answer(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: AiLeaveProgressRequest): LeaveProgressAnswerResult =
service.answer(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.text, request.timezone, request.selectedRequestId)
}
data class AiLeaveProgressRequest(@field:NotBlank @field:Size(max = 2000) val text: String, @field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai", val selectedRequestId: UUID? = null)
@@ -0,0 +1,47 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiDesignerService(
private val gateway: AiDesignerGateway,
private val auditService: AuditService,
) {
fun suggest(actor: CurrentUser, message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult {
actor.requirePermission(ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT)
val normalized = message.trim()
if (normalized.isEmpty() || normalized.length > 2000) {
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
}
if (timezone.isBlank() || timezone.length > 64) {
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
}
val result = gateway.suggest(normalized, history, currentSchema?.takeIf { it.isNotBlank() }, timezone)
if (!result.requiresUserConfirmation) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
}
auditService.recordSuccess(
actor,
"AI_DESIGNER_SUGGESTED",
"AI_SUGGESTION",
"designer",
null,
mapOf(
"model" to result.model,
"promptLength" to normalized.length,
"fieldCount" to result.suggestion.fields.size,
"hasProcess" to (result.suggestion.process != null),
),
)
return result
}
}
@@ -0,0 +1,45 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiLeaveDraftService(
private val gateway: AiLeaveDraftGateway,
private val auditService: AuditService,
) {
fun suggest(actor: CurrentUser, text: String, timezone: String): SuggestedLeaveDraft {
actor.requirePermission(ToolPermission.AI_LEAVE_DRAFT_SUGGEST)
val normalized = text.trim()
if (normalized.isEmpty() || normalized.length > 2000) {
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
}
if (timezone.isBlank() || timezone.length > 64) {
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
}
val result = gateway.suggest(normalized, timezone)
if (!result.requiresUserConfirmation) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
}
auditService.recordSuccess(
actor,
"AI_LEAVE_DRAFT_SUGGESTED",
"AI_SUGGESTION",
"leave-draft",
null,
mapOf(
"model" to result.model,
"promptLength" to normalized.length,
"clarificationCount" to result.suggestion.needsClarification.size,
),
)
return result
}
}
@@ -0,0 +1,55 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.*
import com.all8ai.aioa.approval.domain.LeaveRequest
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiLeaveProgressService(
private val repository: LeaveRequestRepository,
private val workflow: LeaveWorkflowGateway,
private val gateway: AiLeaveProgressGateway,
private val auditService: AuditService,
) {
fun answer(actor: CurrentUser, text: String, timezone: String, selectedRequestId: UUID?): LeaveProgressAnswerResult {
actor.requirePermission(ToolPermission.AI_LEAVE_PROGRESS_READ_OWN)
val question = text.trim()
if (question.isEmpty() || question.length > 2000) throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "问题长度必须为 1 到 2000 个字符")
val zone = try { ZoneId.of(timezone) } catch (_: Exception) { throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效") }
val own = repository.listOwn(actor.tenantId, actor.id, 100)
val matches = selectedRequestId?.let { id -> own.filter { it.id == id } } ?: filter(question, zone, own)
if (selectedRequestId != null && matches.isEmpty()) throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "未找到本人的请假申请")
if (matches.size != 1) return LeaveProgressAnswerResult(true, matches.take(10).map(::candidate))
val request = matches.single()
val rawProgress = request.processInstanceId?.let(workflow::getProgress)
val progress = LeaveProgressView(rawProgress?.activeTaskNames.orEmpty(), rawProgress?.completedTaskNames.orEmpty(), rawProgress?.processEnded ?: request.status.name !in setOf("DRAFT", "PENDING"))
val context = LeaveProgressContext(request.id, request.type.name, request.status.name, request.startsAt, request.endsAt, progress.activeTaskNames, progress.completedTaskNames, progress.processEnded, repository.listTimeline(actor.tenantId, request.id).map { it.eventType })
val generated = gateway.answer(question, timezone, context)
auditService.recordSuccess(actor, "AI_LEAVE_PROGRESS_QUERIED", "LEAVE_REQUEST", request.id.toString(), null, mapOf("model" to generated.model, "promptLength" to question.length, "requestId" to request.id.toString()))
return LeaveProgressAnswerResult(false, answer = generated.answer, request = candidate(request), progress = progress)
}
private fun filter(text: String, zone: ZoneId, requests: List<LeaveRequest>): List<LeaveRequest> {
var result = requests
val type = when { "病假" in text -> "SICK"; "年假" in text -> "ANNUAL"; "事假" in text -> "PERSONAL"; else -> null }
if (type != null) result = result.filter { it.type.name == type }
val today = LocalDate.now(zone)
if ("昨天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today.minusDays(1) }
if ("今天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today }
if (listOf("最近", "最新", "刚才", "刚提交").any { it in text } && result.isNotEmpty()) return listOf(result.maxBy { it.createdAt })
return result
}
private fun candidate(it: LeaveRequest) = LeaveProgressCandidate(it.id, it.type.name, it.status.name, it.startsAt, it.endsAt, it.createdAt)
}
@@ -0,0 +1,57 @@
package com.all8ai.aioa.ai.domain
data class DesignerFormField(
val key: String,
val label: String,
val control: String,
val required: Boolean = false,
val placeholder: String? = null,
val options: List<String>? = null,
val helperText: String? = null,
)
data class DesignerProcessStep(
val name: String,
val assigneeVariable: String,
)
data class DesignerProcessSuggestion(
val mode: String = "SERIAL",
val steps: List<DesignerProcessStep> = emptyList(),
val conditionThresholdDays: Double? = null,
)
data class SchemaValidationIssue(
val field: String,
val issue: String,
val severity: String = "WARNING",
)
data class DesignerSuggestion(
val stage: String = "UNDERSTANDING",
val formTitle: String? = null,
val formKey: String? = null,
val fields: List<DesignerFormField> = emptyList(),
val process: DesignerProcessSuggestion? = null,
val summary: String = "",
val understanding: String = "",
val assumptions: List<String> = emptyList(),
val needsClarification: List<String> = emptyList(),
val validationIssues: List<SchemaValidationIssue> = emptyList(),
val schemaReady: Boolean = false,
)
data class DesignerSuggestionResult(
val suggestion: DesignerSuggestion,
val model: String,
val requiresUserConfirmation: Boolean = true,
)
data class ChatTurn(
val role: String,
val content: String,
)
fun interface AiDesignerGateway {
fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult
}
@@ -0,0 +1,22 @@
package com.all8ai.aioa.ai.domain
import java.time.Instant
data class LeaveDraftSuggestion(
val type: String?,
val startsAt: Instant?,
val endsAt: Instant?,
val reason: String?,
val assumptions: List<String>,
val needsClarification: List<String>,
)
data class SuggestedLeaveDraft(
val suggestion: LeaveDraftSuggestion,
val model: String,
val requiresUserConfirmation: Boolean,
)
fun interface AiLeaveDraftGateway {
fun suggest(text: String, timezone: String): SuggestedLeaveDraft
}
@@ -0,0 +1,45 @@
package com.all8ai.aioa.ai.domain
import java.time.Instant
import java.util.UUID
data class LeaveProgressContext(
val requestId: UUID,
val type: String,
val status: String,
val startsAt: Instant,
val endsAt: Instant,
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
val timelineEventTypes: List<String>,
)
data class GeneratedProgressAnswer(val answer: String, val model: String)
fun interface AiLeaveProgressGateway {
fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer
}
data class LeaveProgressCandidate(
val id: UUID,
val type: String,
val status: String,
val startsAt: Instant,
val endsAt: Instant,
val createdAt: Instant,
)
data class LeaveProgressAnswerResult(
val requiresSelection: Boolean,
val candidates: List<LeaveProgressCandidate> = emptyList(),
val answer: String? = null,
val request: LeaveProgressCandidate? = null,
val progress: LeaveProgressView? = null,
)
data class LeaveProgressView(
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
)
@@ -0,0 +1,31 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.AiDesignerGateway
import com.all8ai.aioa.ai.domain.ChatTurn
import com.all8ai.aioa.ai.domain.DesignerSuggestionResult
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiDesignerGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiDesignerGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun suggest(message: String, history: List<ChatTurn>, currentSchema: String?, timezone: String): DesignerSuggestionResult = try {
client.post()
.uri("/v1/designer/suggest")
.body(DesignerRequest(message, history, currentSchema, timezone))
.retrieve()
.body(DesignerSuggestionResult::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回设计建议")
} catch (e: ApiException) {
throw e
} catch (_: RestClientException) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
}
}
private data class DesignerRequest(val message: String, val history: List<ChatTurn>, val currentSchema: String?, val timezone: String)
@@ -0,0 +1,32 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiLeaveDraftGateway(
@Value("\${aioa.ai-service.url}") aiServiceUrl: String,
) : AiLeaveDraftGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun suggest(text: String, timezone: String): SuggestedLeaveDraft = try {
client.post()
.uri("/v1/leave-drafts/suggest")
.body(SuggestionRequest(text, timezone))
.retrieve()
.body(SuggestedLeaveDraft::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回建议")
} catch (exception: ApiException) {
throw exception
} catch (exception: RestClientException) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
}
}
private data class SuggestionRequest(val text: String, val timezone: String)
@@ -0,0 +1,19 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.*
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiLeaveProgressGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiLeaveProgressGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer = try {
client.post().uri("/v1/leave-progress/answer").body(ProgressRequest(question, timezone, context)).retrieve().body(GeneratedProgressAnswer::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回回答")
} catch (e: ApiException) { throw e } catch (_: RestClientException) { throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用") }
}
private data class ProgressRequest(val question: String, val timezone: String, val context: LeaveProgressContext)
@@ -9,26 +9,32 @@ import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.web.ApiException
import com.all8ai.aioa.shared.web.TraceIdFilter
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.notification.application.NotificationService
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class ApprovalTaskService(
private val workflowGateway: LeaveWorkflowGateway,
private val leaveRequestRepository: LeaveRequestRepository,
private val auditService: AuditService,
private val notificationService: NotificationService? = null,
) {
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
fun listAssigned(actor: CurrentUser): List<ApprovalTask> {
actor.requirePermission(ToolPermission.APPROVAL_TASK_READ_ASSIGNED)
return workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
?: return@mapNotNull null
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
ApprovalTask(task.id, task.name, task.createdAt, request)
}
}
@Transactional
fun approve(
@@ -56,6 +62,7 @@ class ApprovalTaskService(
comment: String?,
approved: Boolean,
): LeaveRequest {
actor.requirePermission(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
validate(idempotencyKey, expectedVersion, comment)
val task = workflowGateway.resolveTask(taskId)
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
@@ -131,6 +138,17 @@ class ApprovalTaskService(
"comment" to comment?.trim(),
),
)
if (completion.processEnded) {
notificationService?.notify(
actor.tenantId,
request.applicantId,
if (approved) "LEAVE_APPROVED" else "LEAVE_REJECTED",
if (approved) "请假申请已通过" else "请假申请已驳回",
if (approved) "你的请假申请已完成审批。" else "你的请假申请未通过审批,请查看审批意见。",
"LEAVE_REQUEST",
request.id.toString(),
)
}
return result
}
@@ -17,11 +17,16 @@ import org.springframework.transaction.annotation.Transactional
import org.slf4j.MDC
import com.all8ai.aioa.shared.web.TraceIdFilter
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.notification.application.NotificationService
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Instant
import java.time.Duration
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
@Service
class LeaveRequestService(
@@ -29,8 +34,11 @@ class LeaveRequestService(
private val auditService: AuditService,
private val routingRepository: ApprovalRoutingRepository,
private val workflowGateway: LeaveWorkflowGateway,
private val notificationService: NotificationService? = null,
private val processBindingRouter: ProcessBindingRouter? = null,
) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
validateIdempotencyKey(idempotencyKey)
val content = command.toValidatedContent()
val fingerprint = fingerprint(content)
@@ -53,6 +61,7 @@ class LeaveRequestService(
}
fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val content = command.toValidatedContent()
repository.updateDraft(actor.tenantId, actor.id, id, command.version, content)?.let { return it }
@@ -64,16 +73,29 @@ class LeaveRequestService(
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
}
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest =
repository.findOwn(actor.tenantId, actor.id, id)
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
return repository.findOwn(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
}
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
fun listOwn(actor: CurrentUser): List<LeaveRequest> {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
return repository.listOwn(actor.tenantId, actor.id, 100)
}
@Transactional
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val processDefinitionKey = processBindingRouter?.select(
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
)?.processDefinitionKey ?: if (processBindingRouter == null) {
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
} else {
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
}
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
if (approverId == actor.id) {
@@ -109,7 +131,8 @@ class LeaveRequestService(
)
if (outcome.replayed) return outcome.leaveRequest
val process = workflowGateway.startLeaveApproval(
val process = workflowGateway.startLeaveApprovalWithDefinition(
processDefinitionKey,
actor.tenantId,
outcome.leaveRequest.id,
actor.id,
@@ -125,6 +148,15 @@ class LeaveRequestService(
process.processInstanceId,
process.processDefinitionId,
)
notificationService?.notify(
actor.tenantId,
approverId,
"APPROVAL_TASK_ASSIGNED",
"新的请假审批待办",
"${actor.displayName} 提交了请假申请,请及时处理。",
"LEAVE_REQUEST",
outcome.leaveRequest.id.toString(),
)
return outcome.leaveRequest.copy(
processInstanceId = process.processInstanceId,
processDefinitionId = process.processDefinitionId,
@@ -133,6 +165,7 @@ class LeaveRequestService(
@Transactional
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val outcome = transition(
actor = actor,
id = id,
@@ -0,0 +1,93 @@
package com.all8ai.aioa.attachment.api
import com.all8ai.aioa.attachment.application.CreateUploadCommand
import com.all8ai.aioa.attachment.application.LeaveAttachmentService
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import jakarta.validation.constraints.Size
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/leave-requests/{leaveRequestId}/attachments")
class LeaveAttachmentController(
private val currentUserService: CurrentUserService,
private val service: LeaveAttachmentService,
) {
@PostMapping("/upload-tasks")
@ResponseStatus(HttpStatus.CREATED)
fun createUpload(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@Valid @RequestBody request: CreateAttachmentUploadRequest,
): AttachmentUploadResponse {
val upload = service.createUpload(currentUser(jwt), leaveRequestId, request.toCommand())
return AttachmentUploadResponse(upload.attachment.toResponse(), upload.uploadUrl)
}
@PostMapping("/{attachmentId}/complete")
fun complete(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = service.complete(currentUser(jwt), leaveRequestId, attachmentId).toResponse()
@GetMapping
fun list(@AuthenticationPrincipal jwt: Jwt, @PathVariable leaveRequestId: UUID) =
service.list(currentUser(jwt), leaveRequestId).map(LeaveAttachment::toResponse)
@GetMapping("/{attachmentId}/download")
fun download(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = AttachmentDownloadResponse(service.download(currentUser(jwt), leaveRequestId, attachmentId))
@DeleteMapping("/{attachmentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = service.delete(currentUser(jwt), leaveRequestId, attachmentId)
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
}
data class CreateAttachmentUploadRequest(
@field:NotBlank @field:Size(max = 255) val fileName: String,
@field:NotBlank @field:Size(max = 128) val contentType: String,
@field:Positive val sizeBytes: Long,
) {
fun toCommand() = CreateUploadCommand(fileName, contentType, sizeBytes)
}
data class AttachmentUploadResponse(val attachment: LeaveAttachmentResponse, val uploadUrl: String)
data class AttachmentDownloadResponse(val downloadUrl: String)
data class LeaveAttachmentResponse(
val id: UUID,
val fileName: String,
val contentType: String,
val sizeBytes: Long,
val status: String,
val createdAt: Instant,
val completedAt: Instant?,
)
private fun LeaveAttachment.toResponse() = LeaveAttachmentResponse(
id, fileName, contentType, sizeBytes, status.name, createdAt, completedAt,
)
@@ -0,0 +1,113 @@
package com.all8ai.aioa.attachment.application
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.attachment.domain.AttachmentStatus
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
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.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class LeaveAttachmentService(
private val leaveRequests: LeaveRequestRepository,
private val attachments: LeaveAttachmentRepository,
private val storage: ObjectStorageGateway,
private val auditService: AuditService,
) {
fun createUpload(actor: CurrentUser, leaveRequestId: UUID, command: CreateUploadCommand): AttachmentUpload {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val fileName = command.fileName.trim().takeIf { it.isNotEmpty() && it.length <= 255 }
?: invalid("ATTACHMENT_NAME_INVALID", "附件名称无效")
if (command.sizeBytes !in 1..MAX_SIZE_BYTES) invalid("ATTACHMENT_SIZE_INVALID", "附件不能超过 10 MB")
if (command.contentType !in ALLOWED_CONTENT_TYPES) invalid("ATTACHMENT_TYPE_INVALID", "不支持该附件类型")
val id = UuidV7.generate()
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_")
val objectKey = "${actor.tenantId}/leave/$leaveRequestId/$id-$safeName"
val attachment = attachments.create(
LeaveAttachment(id, actor.tenantId, leaveRequestId, actor.id, fileName, command.contentType,
command.sizeBytes, objectKey, AttachmentStatus.PENDING, Instant.now(), null),
)
val uploadUrl = storage.createUploadUrl(objectKey)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_UPLOAD_CREATE", "LEAVE_ATTACHMENT", id.toString(), null,
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to command.sizeBytes, "contentType" to command.contentType))
return AttachmentUpload(attachment, uploadUrl)
}
fun complete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
if (attachment.status == AttachmentStatus.READY) return attachment
val stored = storage.stat(attachment.objectKey)
if (stored.sizeBytes != attachment.sizeBytes) invalid("ATTACHMENT_SIZE_MISMATCH", "附件大小校验失败")
if (stored.contentType != null && stored.contentType != attachment.contentType) {
invalid("ATTACHMENT_TYPE_MISMATCH", "附件类型校验失败")
}
val completed = attachments.markReady(actor.tenantId, attachmentId)
?: throw ApiException(HttpStatus.CONFLICT, "ATTACHMENT_STATE_CONFLICT", "附件状态已变化")
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_COMPLETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to completed.sizeBytes))
return completed
}
fun list(actor: CurrentUser, leaveRequestId: UUID): List<LeaveAttachment> {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwn(actor, leaveRequestId)
return attachments.list(actor.tenantId, leaveRequestId)
}
fun download(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): String {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwn(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
if (attachment.status != AttachmentStatus.READY) invalid("ATTACHMENT_NOT_READY", "附件尚未上传完成")
val downloadUrl = storage.createDownloadUrl(attachment.objectKey)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DOWNLOAD", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId))
return downloadUrl
}
fun delete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID) {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
storage.delete(attachment.objectKey)
attachments.delete(actor.tenantId, attachmentId)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DELETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId))
}
private fun find(actor: CurrentUser, requestId: UUID, attachmentId: UUID) =
attachments.find(actor.tenantId, requestId, attachmentId)
?: throw ApiException(HttpStatus.NOT_FOUND, "ATTACHMENT_NOT_FOUND", "附件不存在")
private fun requireOwn(actor: CurrentUser, id: UUID) = leaveRequests.findOwn(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
private fun requireOwnDraft(actor: CurrentUser, id: UUID) {
if (requireOwn(actor, id).status != LeaveStatus.DRAFT) {
throw ApiException(HttpStatus.CONFLICT, "LEAVE_REQUEST_NOT_DRAFT", "只有草稿可以修改附件")
}
}
private fun invalid(code: String, message: String): Nothing = throw ApiException(HttpStatus.BAD_REQUEST, code, message)
companion object {
const val MAX_SIZE_BYTES = 10L * 1024 * 1024
val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "application/pdf")
}
}
data class CreateUploadCommand(val fileName: String, val contentType: String, val sizeBytes: Long)
data class AttachmentUpload(val attachment: LeaveAttachment, val uploadUrl: String)
@@ -0,0 +1,37 @@
package com.all8ai.aioa.attachment.domain
import java.time.Instant
import java.util.UUID
enum class AttachmentStatus { PENDING, READY }
data class LeaveAttachment(
val id: UUID,
val tenantId: UUID,
val leaveRequestId: UUID,
val uploaderId: UUID,
val fileName: String,
val contentType: String,
val sizeBytes: Long,
val objectKey: String,
val status: AttachmentStatus,
val createdAt: Instant,
val completedAt: Instant?,
)
interface LeaveAttachmentRepository {
fun create(attachment: LeaveAttachment): LeaveAttachment
fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment?
fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment>
fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment?
fun delete(tenantId: UUID, attachmentId: UUID): Boolean
}
interface ObjectStorageGateway {
fun createUploadUrl(objectKey: String): String
fun stat(objectKey: String): StoredObject
fun createDownloadUrl(objectKey: String): String
fun delete(objectKey: String)
}
data class StoredObject(val sizeBytes: Long, val contentType: String?)
@@ -0,0 +1,66 @@
package com.all8ai.aioa.attachment.infrastructure
import com.all8ai.aioa.attachment.domain.AttachmentStatus
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqLeaveAttachmentRepository(private val dsl: DSLContext) : LeaveAttachmentRepository {
override fun create(attachment: LeaveAttachment): LeaveAttachment {
dsl.execute(
"""
INSERT INTO business.leave_attachment (
id, tenant_id, leave_request_id, uploader_id, file_name,
content_type, size_bytes, object_key, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
attachment.id, attachment.tenantId, attachment.leaveRequestId, attachment.uploaderId,
attachment.fileName, attachment.contentType, attachment.sizeBytes, attachment.objectKey,
attachment.status.name,
)
return find(attachment.tenantId, attachment.leaveRequestId, attachment.id)!!
}
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment? =
dsl.fetchOne(
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? AND id = ?",
tenantId, leaveRequestId, attachmentId,
)?.let(::map)
override fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment> = dsl.fetch(
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? ORDER BY created_at, id",
tenantId, leaveRequestId,
).map(::map)
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = dsl.fetchOne(
"""
UPDATE business.leave_attachment
SET status = 'READY', completed_at = CURRENT_TIMESTAMP
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
RETURNING *
""".trimIndent(),
tenantId, attachmentId,
)?.let(::map)
override fun delete(tenantId: UUID, attachmentId: UUID): Boolean =
dsl.execute("DELETE FROM business.leave_attachment WHERE tenant_id = ? AND id = ?", tenantId, attachmentId) == 1
private fun map(record: Record) = LeaveAttachment(
id = record.get("id", UUID::class.java)!!,
tenantId = record.get("tenant_id", UUID::class.java)!!,
leaveRequestId = record.get("leave_request_id", UUID::class.java)!!,
uploaderId = record.get("uploader_id", UUID::class.java)!!,
fileName = record.get("file_name", String::class.java)!!,
contentType = record.get("content_type", String::class.java)!!,
sizeBytes = record.get("size_bytes", Long::class.java)!!,
objectKey = record.get("object_key", String::class.java)!!,
status = AttachmentStatus.valueOf(record.get("status", String::class.java)!!),
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
completedAt = record.get("completed_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,51 @@
package com.all8ai.aioa.attachment.infrastructure
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
import com.all8ai.aioa.attachment.domain.StoredObject
import io.minio.BucketExistsArgs
import io.minio.GetPresignedObjectUrlArgs
import io.minio.MakeBucketArgs
import io.minio.MinioClient
import io.minio.RemoveObjectArgs
import io.minio.StatObjectArgs
import io.minio.http.Method
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.concurrent.TimeUnit
@Component
class MinioObjectStorageGateway(
@Value("\${aioa.object-storage.endpoint}") endpoint: String,
@Value("\${aioa.object-storage.access-key}") accessKey: String,
@Value("\${aioa.object-storage.secret-key}") secretKey: String,
@Value("\${aioa.object-storage.bucket}") private val bucket: String,
) : ObjectStorageGateway {
private val client = MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build()
override fun createUploadUrl(objectKey: String): String {
ensureBucket()
return client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).`object`(objectKey)
.expiry(15, TimeUnit.MINUTES).build(),
)
}
override fun stat(objectKey: String): StoredObject = client.statObject(
StatObjectArgs.builder().bucket(bucket).`object`(objectKey).build(),
).let { StoredObject(it.size(), it.contentType()) }
override fun createDownloadUrl(objectKey: String): String = client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucket).`object`(objectKey)
.expiry(5, TimeUnit.MINUTES).build(),
)
override fun delete(objectKey: String) {
client.removeObject(RemoveObjectArgs.builder().bucket(bucket).`object`(objectKey).build())
}
private fun ensureBucket() {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build())
}
}
}
@@ -0,0 +1,25 @@
package com.all8ai.aioa.audit.api
import com.all8ai.aioa.audit.application.AuditQueryService
import com.all8ai.aioa.audit.application.RedactedAuditEvent
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.constraints.Max
import jakarta.validation.constraints.Min
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/v1/admin/audit-events")
class AuditQueryController(private val currentUserService: CurrentUserService, private val service: AuditQueryService) {
@GetMapping
fun list(
@AuthenticationPrincipal jwt: Jwt,
@RequestParam(required = false) traceId: String?,
@RequestParam(required = false) action: String?,
@RequestParam(required = false) resourceType: String?,
@RequestParam(defaultValue = "100") @Min(1) @Max(200) limit: Int,
): List<RedactedAuditEvent> = service.list(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), traceId, action, resourceType, limit,
)
}
@@ -0,0 +1,44 @@
package com.all8ai.aioa.audit.application
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
@Service
class AuditQueryService(private val repository: AuditQueryRepository) {
fun list(actor: CurrentUser, traceId: String?, action: String?, resourceType: String?, limit: Int): List<RedactedAuditEvent> {
actor.requirePermission(ToolPermission.AUDIT_READ_TENANT_REDACTED)
if (limit !in 1..200) throw ApiException(HttpStatus.BAD_REQUEST, "AUDIT_LIMIT_INVALID", "查询数量必须为 1 到 200")
val normalizedTrace = normalize(traceId, 128, "AUDIT_TRACE_ID_INVALID")
val normalizedAction = normalize(action, 120, "AUDIT_ACTION_INVALID")
val normalizedType = normalize(resourceType, 120, "AUDIT_RESOURCE_TYPE_INVALID")
return repository.list(actor.tenantId, normalizedTrace, normalizedAction, normalizedType, limit).map { event ->
RedactedAuditEvent(event.id, event.actorId, event.action, event.resourceType, event.resourceId, event.traceId,
event.result, event.occurredAt, event.details.filterKeys(SAFE_DETAIL_KEYS::contains))
}
}
private fun normalize(value: String?, max: Int, code: String): String? {
if (value == null) return null
val normalized = value.trim()
if (normalized.isEmpty() || normalized.length > max || !normalized.matches(Regex("[A-Za-z0-9._:-]+"))) {
throw ApiException(HttpStatus.BAD_REQUEST, code, "审计查询条件无效")
}
return normalized
}
companion object {
private val SAFE_DETAIL_KEYS = setOf("model", "promptLength", "clarificationCount", "requestId", "taskId", "decision", "processEnded", "fromStatus", "toStatus", "version", "leaveRequestId", "sizeBytes", "contentType", "platform")
}
}
data class RedactedAuditEvent(
val id: UUID, val actorId: UUID, val action: String, val resourceType: String, val resourceId: String?,
val traceId: String, val result: String, val occurredAt: Instant, val details: Map<String, Any?>,
)
@@ -1,6 +1,7 @@
package com.all8ai.aioa.audit.domain
import java.util.UUID
import java.time.Instant
data class AuditEvent(
val id: UUID,
@@ -18,3 +19,26 @@ data class AuditEvent(
fun interface AuditEventRepository {
fun append(event: AuditEvent)
}
data class StoredAuditEvent(
val id: UUID,
val tenantId: UUID,
val actorId: UUID,
val action: String,
val resourceType: String,
val resourceId: String?,
val traceId: String,
val result: String,
val occurredAt: Instant,
val details: Map<String, Any?>,
)
interface AuditQueryRepository {
fun list(
tenantId: UUID,
traceId: String?,
action: String?,
resourceType: String?,
limit: Int,
): List<StoredAuditEvent>
}
@@ -2,15 +2,20 @@ package com.all8ai.aioa.audit.infrastructure
import com.all8ai.aioa.audit.domain.AuditEvent
import com.all8ai.aioa.audit.domain.AuditEventRepository
import com.all8ai.aioa.audit.domain.AuditQueryRepository
import com.all8ai.aioa.audit.domain.StoredAuditEvent
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
import org.jooq.impl.DSL
@Repository
class JooqAuditEventRepository(
private val dsl: DSLContext,
private val objectMapper: ObjectMapper,
) : AuditEventRepository {
) : AuditEventRepository, AuditQueryRepository {
override fun append(event: AuditEvent) {
dsl.execute(
"""
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
objectMapper.writeValueAsString(event.details),
)
}
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
val table = DSL.table(DSL.name("audit", "event"))
var condition = DSL.field(DSL.name("tenant_id"), UUID::class.java).eq(tenantId)
traceId?.let { condition = condition.and(DSL.field(DSL.name("trace_id"), String::class.java).eq(it)) }
action?.let { condition = condition.and(DSL.field(DSL.name("action"), String::class.java).eq(it)) }
resourceType?.let { condition = condition.and(DSL.field(DSL.name("resource_type"), String::class.java).eq(it)) }
return dsl.select().from(table).where(condition)
.orderBy(DSL.field(DSL.name("occurred_at")).desc()).limit(limit).fetch().map { record ->
@Suppress("UNCHECKED_CAST")
StoredAuditEvent(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("actor_id", UUID::class.java)!!,
record.get("action", String::class.java)!!,
record.get("resource_type", String::class.java)!!,
record.get("resource_id", String::class.java),
record.get("trace_id", String::class.java)!!,
record.get("result", String::class.java)!!,
record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(),
objectMapper.readValue(record.get("details")!!.toString(), Map::class.java) as Map<String, Any?>,
)
}
}
}
@@ -0,0 +1,49 @@
package com.all8ai.aioa.device.api
import com.all8ai.aioa.device.application.UserDeviceService
import com.all8ai.aioa.device.domain.DevicePlatform
import com.all8ai.aioa.device.domain.UserDevice
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/devices")
class UserDeviceController(private val currentUserService: CurrentUserService, private val service: UserDeviceService) {
@PostMapping("/register")
fun register(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: RegisterDeviceRequest): UserDevice =
service.register(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.id, request.name, request.platform, request.appVersion)
@GetMapping fun list(@AuthenticationPrincipal jwt: Jwt): List<UserDevice> =
service.list(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
@DeleteMapping("/{id}") fun revoke(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID): UserDevice =
service.revoke(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
@PutMapping("/{id}/push-token")
fun updatePushToken(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable id: UUID,
@RequestHeader("X-AIOA-Device-Id") currentDeviceId: UUID,
@Valid @RequestBody request: PushTokenRequest,
) {
if (id != currentDeviceId) throw com.all8ai.aioa.shared.web.ApiException(
org.springframework.http.HttpStatus.FORBIDDEN, "DEVICE_MISMATCH", "只能更新当前设备的推送令牌",
)
service.updatePushToken(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.token)
}
}
data class RegisterDeviceRequest(
val id: UUID,
@field:NotBlank @field:Size(max = 200) val name: String,
val platform: DevicePlatform,
@field:Size(max = 64) val appVersion: String? = null,
)
data class PushTokenRequest(@field:Size(max = 4096) val token: String?)
@@ -0,0 +1,40 @@
package com.all8ai.aioa.device.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.device.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.util.UUID
import org.springframework.transaction.annotation.Transactional
@Service
class UserDeviceService(private val repository: UserDeviceRepository, private val auditService: AuditService) {
fun register(actor: CurrentUser, id: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice {
val normalizedName = name.trim().takeIf { it.isNotEmpty() && it.length <= 200 }
?: throw ApiException(HttpStatus.BAD_REQUEST, "DEVICE_NAME_INVALID", "设备名称无效")
val normalizedVersion = appVersion?.trim()?.takeIf { it.isNotEmpty() && it.length <= 64 }
return repository.register(id, actor.tenantId, actor.id, normalizedName, platform, normalizedVersion)
?: throw ApiException(HttpStatus.UNAUTHORIZED, "DEVICE_REVOKED", "该设备已被撤销,请联系管理员或使用其他设备")
}
fun list(actor: CurrentUser): List<UserDevice> = repository.list(actor.tenantId, actor.id)
fun revoke(actor: CurrentUser, id: UUID): UserDevice {
val device = repository.revoke(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在")
auditService.recordSuccess(actor, "USER_DEVICE_REVOKED", "USER_DEVICE", id.toString(), null, mapOf("platform" to device.platform.name))
return device
}
@Transactional
fun updatePushToken(actor: CurrentUser, id: UUID, token: String?) {
val normalized = token?.trim()?.takeIf { it.isNotEmpty() && it.length <= 4096 }
if (token != null && normalized == null) throw ApiException(HttpStatus.BAD_REQUEST, "PUSH_TOKEN_INVALID", "推送令牌无效")
normalized?.let(repository::clearPushToken)
if (!repository.updatePushToken(actor.tenantId, actor.id, id, normalized)) {
throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在或已撤销")
}
}
}
@@ -0,0 +1,30 @@
package com.all8ai.aioa.device.domain
import java.time.Instant
import java.util.UUID
enum class DeviceStatus { ACTIVE, REVOKED }
enum class DevicePlatform { IOS, ANDROID, OTHER }
data class UserDevice(
val id: UUID,
val tenantId: UUID,
val userId: UUID,
val name: String,
val platform: DevicePlatform,
val appVersion: String?,
val status: DeviceStatus,
val registeredAt: Instant,
val lastSeenAt: Instant,
val revokedAt: Instant?,
)
interface UserDeviceRepository {
fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice?
fun list(tenantId: UUID, userId: UUID): List<UserDevice>
fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice?
fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean
fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean
fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String>
fun clearPushToken(token: String)
}
@@ -0,0 +1,39 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.UUID
@Component
class DeviceSessionInterceptor(
private val currentUserService: CurrentUserService,
private val devices: UserDeviceRepository,
) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.method == "OPTIONS" || !request.requestURI.startsWith("/api/v1/") || request.requestURI == "/api/v1/devices/register") return true
val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt ?: return true
val actor = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val deviceId = request.getHeader(DEVICE_ID_HEADER)?.let { value ->
runCatching { UUID.fromString(value) }.getOrNull()
}
if (deviceId != null && devices.touchActive(actor.tenantId, actor.id, deviceId)) return true
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.setHeader(AUTH_ERROR_HEADER, "DEVICE_REVOKED")
response.writer.write("""{"title":"Unauthorized","status":401,"detail":"设备未注册或已被撤销","code":"DEVICE_REVOKED"}""")
return false
}
companion object {
const val DEVICE_ID_HEADER = "X-AIOA-Device-Id"
const val AUTH_ERROR_HEADER = "X-AIOA-Auth-Error"
}
}
@@ -0,0 +1,12 @@
package com.all8ai.aioa.device.infrastructure
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class DeviceWebConfiguration(private val interceptor: DeviceSessionInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(interceptor)
}
}
@@ -0,0 +1,68 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqUserDeviceRepository(private val dsl: DSLContext) : UserDeviceRepository {
override fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice? =
dsl.fetchOne(
"""
INSERT INTO identity.user_device (id, tenant_id, user_id, name, platform, app_version)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id, id) DO UPDATE SET
name = EXCLUDED.name, platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version, last_seen_at = CURRENT_TIMESTAMP
WHERE user_device.status = 'ACTIVE'
RETURNING *
""".trimIndent(), id, tenantId, userId, name, platform.name, appVersion,
)?.let(::map)
override fun list(tenantId: UUID, userId: UUID): List<UserDevice> = dsl.fetch(
"SELECT * FROM identity.user_device WHERE tenant_id = ? AND user_id = ? ORDER BY last_seen_at DESC",
tenantId, userId,
).map(::map)
override fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice? = dsl.fetchOne(
"""
UPDATE identity.user_device SET status = 'REVOKED', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND user_id = ? AND id = ? RETURNING *
""".trimIndent(), tenantId, userId, id,
)?.let(::map)
override fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean = dsl.execute(
"UPDATE identity.user_device SET last_seen_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
tenantId, userId, id,
) == 1
override fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean = dsl.execute(
"UPDATE identity.user_device SET push_token = ?, push_token_updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
token, tenantId, userId, id,
) == 1
override fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String> = dsl.fetch(
"SELECT push_token FROM identity.user_device WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE' AND push_token IS NOT NULL",
tenantId, userId,
).map { it.get("push_token", String::class.java)!! }
override fun clearPushToken(token: String) {
dsl.execute("UPDATE identity.user_device SET push_token = NULL, push_token_updated_at = CURRENT_TIMESTAMP WHERE push_token = ?", token)
}
private fun map(record: Record) = UserDevice(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("user_id", UUID::class.java)!!,
record.get("name", String::class.java)!!,
DevicePlatform.valueOf(record.get("platform", String::class.java)!!),
record.get("app_version", String::class.java),
DeviceStatus.valueOf(record.get("status", String::class.java)!!),
record.get("registered_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("last_seen_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("revoked_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,64 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.shared.web.ApiException
import com.fasterxml.jackson.databind.ObjectMapper
import org.jooq.DSLContext
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/form-definitions")
class FormDefinitionController(
private val currentUsers: CurrentUserService? = null,
private val dsl: DSLContext? = null,
private val objectMapper: ObjectMapper? = null,
) {
@GetMapping("/{formKey}")
fun getPublished(@AuthenticationPrincipal jwt: Jwt, @PathVariable formKey: String): FormDefinitionResponse {
val actor = currentUsers!!.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
return find(actor.tenantId, formKey) ?: notFound()
}
// 保留纯单元测试与离线内置定义入口;生产 HTTP 始终读取已发布数据库版本。
fun getDefinition(formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> notFound()
}
private fun find(tenantId: UUID, key: String): FormDefinitionResponse? = dsl!!.fetchOne(
"SELECT * FROM form.definition WHERE tenant_id=? AND form_key=? AND status='PUBLISHED'",
tenantId, key,
)?.let {
@Suppress("UNCHECKED_CAST")
FormDefinitionResponse(
it.get("form_key", String::class.java)!!,
it.get("version", Int::class.java)!!,
objectMapper!!.readValue(it.get("data_schema")!!.toString(), Map::class.java) as Map<String, Any>,
objectMapper.readValue(it.get("ui_schema")!!.toString(), Map::class.java) as Map<String, Any>,
)
}
private fun notFound(): Nothing = throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
companion object {
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
val leaveRequestDefinition = FormDefinitionResponse(
LEAVE_REQUEST_FORM_KEY, 1,
mapOf("\$id" to "leave-request-v1", "title" to "请假申请", "type" to "object", "required" to listOf("type", "startsAt", "endsAt", "reason"), "properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"), "endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000))),
mapOf("description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。", "sections" to listOf(
mapOf("title" to "请假信息", "controls" to listOf(
mapOf("field" to "type", "label" to "请假类型", "control" to "select", "optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假")),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"), mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"))),
mapOf("title" to "补充说明", "controls" to listOf(mapOf("field" to "reason", "label" to "请假原因", "control" to "textArea", "placeholder" to "请简要说明请假原因", "helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。"))))),
)
}
}
data class FormDefinitionResponse(val key: String, val version: Int, val dataSchema: Map<String, Any>, val uiSchema: Map<String, Any>)
@@ -7,6 +7,9 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
import com.all8ai.aioa.shared.security.AuthorizationPolicy
import com.all8ai.aioa.shared.security.DataScope
import com.all8ai.aioa.shared.security.ToolPermission
@RestController
@RequestMapping("/api/v1/me")
@@ -16,6 +19,7 @@ class CurrentUserController(
@GetMapping
fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse {
val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val capabilities = AuthorizationPolicy.capabilities(currentUser)
return CurrentUserResponse(
id = currentUser.id,
tenantId = currentUser.tenantId,
@@ -25,6 +29,8 @@ class CurrentUserController(
department = currentUser.department?.let { OrganizationRef(it.id, it.name) },
position = currentUser.position?.let { OrganizationRef(it.id, it.name) },
roles = currentUser.roles,
permissions = capabilities.permissions,
dataScopes = capabilities.dataScopes,
)
}
}
@@ -38,6 +44,8 @@ data class CurrentUserResponse(
val department: OrganizationRef?,
val position: OrganizationRef?,
val roles: Set<String>,
val permissions: Set<ToolPermission>,
val dataScopes: Set<DataScope>,
)
data class OrganizationRef(
@@ -0,0 +1,49 @@
package com.all8ai.aioa.notification.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.notification.application.NotificationService
import com.all8ai.aioa.notification.domain.Notification
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.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/notifications")
class NotificationController(
private val currentUserService: CurrentUserService,
private val service: NotificationService,
) {
@GetMapping
fun list(@AuthenticationPrincipal jwt: Jwt) = service.list(currentUser(jwt)).map(Notification::toResponse)
@GetMapping("/unread-count")
fun unreadCount(@AuthenticationPrincipal jwt: Jwt) = UnreadCountResponse(service.unreadCount(currentUser(jwt)))
@PostMapping("/{id}/read")
fun markRead(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID) =
service.markRead(currentUser(jwt), id).toResponse()
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
}
data class UnreadCountResponse(val unreadCount: Int)
data class NotificationResponse(
val id: UUID,
val type: String,
val title: String,
val body: String,
val resourceType: String?,
val resourceId: String?,
val createdAt: Instant,
val readAt: Instant?,
)
private fun Notification.toResponse() = NotificationResponse(
id, type, title, body, resourceType, resourceId, createdAt, readAt,
)
@@ -0,0 +1,59 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.notification.domain.Notification
import com.all8ai.aioa.notification.domain.NotificationRepository
import com.all8ai.aioa.shared.id.UuidV7
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
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.notification.domain.PushOutboxRepository
import org.springframework.transaction.annotation.Transactional
@Service
class NotificationService(
private val repository: NotificationRepository,
private val auditService: AuditService,
private val pushOutbox: PushOutboxRepository? = null,
) {
@Transactional
fun notify(
tenantId: UUID,
recipientId: UUID,
type: String,
title: String,
body: String,
resourceType: String? = null,
resourceId: String? = null,
): Notification {
val notification = repository.create(
Notification(UuidV7.generate(), tenantId, recipientId, type, title, body,
resourceType, resourceId, Instant.now(), null),
)
pushOutbox?.enqueue(notification.id)
return notification
}
fun list(actor: CurrentUser): List<Notification> {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
return repository.list(actor.tenantId, actor.id, 100)
}
fun unreadCount(actor: CurrentUser): Int {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
return repository.countUnread(actor.tenantId, actor.id)
}
fun markRead(actor: CurrentUser, id: UUID): Notification {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
val notification = repository.markRead(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "通知不存在")
auditService.recordSuccess(actor, "NOTIFICATION_READ", "NOTIFICATION", id.toString(), null, emptyMap())
return notification
}
}
@@ -0,0 +1,43 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.notification.domain.*
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class PushDispatcher(
private val outbox: PushOutboxRepository,
private val devices: UserDeviceRepository,
private val gateway: PushGateway,
) {
@Scheduled(fixedDelayString = "\${aioa.push.dispatch-interval-ms:5000}")
fun dispatch() {
outbox.findPending(50).forEach(::dispatchOne)
}
private fun dispatchOne(pending: PendingPush) {
val notification = pending.notification
val tokens = devices.listActivePushTokens(notification.tenantId, notification.recipientId)
if (tokens.isEmpty()) return outbox.markDelivered(notification.id)
var delivered = false
var disabled = false
var failed = false
tokens.forEach { token ->
when (gateway.send(token, notification)) {
PushSendResult.DELIVERED -> delivered = true
PushSendResult.INVALID_TOKEN -> devices.clearPushToken(token)
PushSendResult.DISABLED -> disabled = true
PushSendResult.FAILED -> failed = true
}
}
when {
delivered || (!disabled && !failed) -> outbox.markDelivered(notification.id)
disabled -> outbox.reschedule(notification.id, pending.attempts, 3600, "Firebase push is not configured")
else -> {
val attempts = pending.attempts + 1
outbox.reschedule(notification.id, attempts, minOf(3600, 1L shl minOf(attempts, 10)), "Firebase delivery failed")
}
}
}
}
@@ -0,0 +1,24 @@
package com.all8ai.aioa.notification.domain
import java.time.Instant
import java.util.UUID
data class Notification(
val id: UUID,
val tenantId: UUID,
val recipientId: UUID,
val type: String,
val title: String,
val body: String,
val resourceType: String?,
val resourceId: String?,
val createdAt: Instant,
val readAt: Instant?,
)
interface NotificationRepository {
fun create(notification: Notification): Notification
fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification>
fun countUnread(tenantId: UUID, recipientId: UUID): Int
fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification?
}
@@ -0,0 +1,18 @@
package com.all8ai.aioa.notification.domain
import java.util.UUID
data class PendingPush(val notification: Notification, val attempts: Int)
interface PushOutboxRepository {
fun enqueue(notificationId: UUID)
fun findPending(limit: Int): List<PendingPush>
fun markDelivered(notificationId: UUID)
fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String)
}
enum class PushSendResult { DELIVERED, INVALID_TOKEN, DISABLED, FAILED }
fun interface PushGateway {
fun send(token: String, notification: Notification): PushSendResult
}
@@ -0,0 +1,41 @@
package com.all8ai.aioa.notification.infrastructure
import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingException
import com.google.firebase.messaging.Message
import com.all8ai.aioa.notification.domain.*
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.io.FileInputStream
@Component
class FirebasePushGateway(
@Value("\${aioa.push.firebase-credentials-file:}") credentialsFile: String,
) : PushGateway {
private val messaging: FirebaseMessaging? = credentialsFile.trim().takeIf { it.isNotEmpty() }?.let { path ->
val options = FileInputStream(path).use { FirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(it)).build() }
FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options, "aioa-push"))
}
override fun send(token: String, notification: Notification): PushSendResult {
val client = messaging ?: return PushSendResult.DISABLED
val message = Message.builder().setToken(token)
.setNotification(com.google.firebase.messaging.Notification.builder().setTitle(notification.title).setBody(notification.body).build())
.putData("type", notification.type)
.apply {
notification.resourceType?.let { putData("resourceType", it) }
notification.resourceId?.let { putData("resourceId", it) }
}.build()
return try {
client.send(message)
PushSendResult.DELIVERED
} catch (exception: FirebaseMessagingException) {
if (exception.messagingErrorCode?.name in setOf("UNREGISTERED", "INVALID_ARGUMENT")) PushSendResult.INVALID_TOKEN else PushSendResult.FAILED
} catch (_: Exception) {
PushSendResult.FAILED
}
}
}
@@ -0,0 +1,62 @@
package com.all8ai.aioa.notification.infrastructure
import com.all8ai.aioa.notification.domain.Notification
import com.all8ai.aioa.notification.domain.NotificationRepository
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqNotificationRepository(private val dsl: DSLContext) : NotificationRepository {
override fun create(notification: Notification): Notification {
dsl.execute(
"""
INSERT INTO communication.notification (
id, tenant_id, recipient_id, type, title, body,
resource_type, resource_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
notification.id, notification.tenantId, notification.recipientId,
notification.type, notification.title, notification.body,
notification.resourceType, notification.resourceId,
)
return notification
}
override fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification> = dsl.fetch(
"""
SELECT * FROM communication.notification
WHERE tenant_id = ? AND recipient_id = ?
ORDER BY created_at DESC, id DESC LIMIT ?
""".trimIndent(),
tenantId, recipientId, limit,
).map(::map)
override fun countUnread(tenantId: UUID, recipientId: UUID): Int = dsl.fetchOne(
"SELECT COUNT(*) AS count FROM communication.notification WHERE tenant_id = ? AND recipient_id = ? AND read_at IS NULL",
tenantId, recipientId,
)!!.get("count", Int::class.java)!!
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = dsl.fetchOne(
"""
UPDATE communication.notification SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND recipient_id = ? AND id = ? RETURNING *
""".trimIndent(),
tenantId, recipientId, id,
)?.let(::map)
private fun map(record: Record) = Notification(
id = record.get("id", UUID::class.java)!!,
tenantId = record.get("tenant_id", UUID::class.java)!!,
recipientId = record.get("recipient_id", UUID::class.java)!!,
type = record.get("type", String::class.java)!!,
title = record.get("title", String::class.java)!!,
body = record.get("body", String::class.java)!!,
resourceType = record.get("resource_type", String::class.java),
resourceId = record.get("resource_id", String::class.java),
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
readAt = record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,47 @@
package com.all8ai.aioa.notification.infrastructure
import com.all8ai.aioa.notification.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqPushOutboxRepository(private val dsl: DSLContext) : PushOutboxRepository {
override fun enqueue(notificationId: UUID) {
dsl.execute("INSERT INTO communication.notification_push_outbox (notification_id) VALUES (?) ON CONFLICT DO NOTHING", notificationId)
}
override fun findPending(limit: Int): List<PendingPush> = dsl.fetch(
"""
SELECT n.*, o.attempts FROM communication.notification_push_outbox o
JOIN communication.notification n ON n.id = o.notification_id
WHERE o.status = 'PENDING' AND o.next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY o.next_attempt_at, o.notification_id LIMIT ?
""".trimIndent(), limit,
).map(::map)
override fun markDelivered(notificationId: UUID) {
dsl.execute("UPDATE communication.notification_push_outbox SET status = 'DELIVERED', delivered_at = CURRENT_TIMESTAMP WHERE notification_id = ?", notificationId)
}
override fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String) {
dsl.execute(
"UPDATE communication.notification_push_outbox SET attempts = ?, next_attempt_at = CURRENT_TIMESTAMP + (? * INTERVAL '1 second'), last_error = ? WHERE notification_id = ?",
attempts, delaySeconds, error.take(500), notificationId,
)
}
private fun map(record: Record) = PendingPush(
Notification(
record.get("id", UUID::class.java)!!, record.get("tenant_id", UUID::class.java)!!,
record.get("recipient_id", UUID::class.java)!!, record.get("type", String::class.java)!!,
record.get("title", String::class.java)!!, record.get("body", String::class.java)!!,
record.get("resource_type", String::class.java), record.get("resource_id", String::class.java),
record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
),
record.get("attempts", Int::class.java)!!,
)
}
@@ -0,0 +1,72 @@
package com.all8ai.aioa.shared.security
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
enum class ToolPermission {
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,
}
enum class DataScope { OWN, ASSIGNED, TENANT }
data class UserCapabilities(
val permissions: Set<ToolPermission>,
val dataScopes: Set<DataScope>,
)
object AuthorizationPolicy {
private val employeePermissions = setOf(
ToolPermission.LEAVE_REQUEST_READ_OWN,
ToolPermission.LEAVE_REQUEST_WRITE_OWN,
ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN,
ToolPermission.NOTIFICATION_READ_OWN,
ToolPermission.AI_LEAVE_DRAFT_SUGGEST,
ToolPermission.AI_LEAVE_PROGRESS_READ_OWN,
)
private val approverRoles = setOf("department_manager", "oa_admin", "hr_reviewer")
private val approvalPermissions = setOf(
ToolPermission.APPROVAL_TASK_READ_ASSIGNED,
ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED,
)
fun capabilities(user: CurrentUser): UserCapabilities {
val permissions = buildSet {
if ("employee" in user.roles) addAll(employeePermissions)
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
if ("oa_admin" in user.roles) addAll(setOf(
ToolPermission.AUDIT_READ_TENANT_REDACTED,
ToolPermission.ORGANIZATION_MANAGE_TENANT,
ToolPermission.WORKFLOW_READ_TENANT,
ToolPermission.OPERATIONS_METRICS_READ_TENANT,
ToolPermission.PROCESS_CONFIGURATION_MANAGE_TENANT,
))
}
return UserCapabilities(
permissions,
buildSet {
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
if (permissions.any { it.name.endsWith("_TENANT") }) add(DataScope.TENANT)
},
)
}
}
fun CurrentUser.requirePermission(permission: ToolPermission) {
if (permission !in AuthorizationPolicy.capabilities(this).permissions) {
throw ApiException(HttpStatus.FORBIDDEN, "PERMISSION_DENIED", "当前用户无权使用该功能")
}
}
@@ -5,16 +5,31 @@ import org.springframework.context.annotation.Configuration
import org.springframework.security.config.Customizer.withDefaults
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.web.SecurityFilterChain
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
@Configuration
class SecurityConfiguration {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
.csrf { it.disable() }
.cors(withDefaults())
.authorizeHttpRequests {
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.anyRequest().authenticated()
}
.oauth2ResourceServer { it.jwt(withDefaults()) }
.build()
@Bean
fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/api/**", CorsConfiguration().apply {
allowedOrigins = listOf("http://localhost:5173", "http://127.0.0.1:5173")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("Authorization", "Content-Type", "X-AIOA-Device-Id", "Idempotency-Key", "X-Trace-Id")
exposedHeaders = listOf("X-Trace-Id", "X-AIOA-Auth-Error")
allowCredentials = true
})
}
}
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
leaveType: String,
): StartedProcess
fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
fun resolveTask(taskId: String): WorkflowTask?
@@ -22,6 +34,9 @@ interface LeaveWorkflowGateway {
fun completeTask(taskId: String, approved: Boolean, comment: String?): TaskCompletion
fun cancelProcess(processInstanceId: String, reason: String)
fun getProgress(processInstanceId: String): WorkflowProgress =
error("Workflow progress is not supported")
}
data class StartedProcess(
@@ -43,3 +58,9 @@ data class TaskCompletion(
val processInstanceId: String,
val processEnded: Boolean,
)
data class WorkflowProgress(
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
)
@@ -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?
}
@@ -4,6 +4,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.workflow.domain.StartedProcess
import com.all8ai.aioa.workflow.domain.WorkflowTask
import com.all8ai.aioa.workflow.domain.TaskCompletion
import com.all8ai.aioa.workflow.domain.WorkflowProgress
import org.flowable.engine.HistoryService
import org.flowable.engine.RuntimeService
import org.flowable.engine.TaskService
@@ -26,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
override fun startLeaveApprovalWithDefinition(
processDefinitionKey: String,
tenantId: UUID,
leaveRequestId: UUID,
applicantId: UUID,
approverId: UUID,
oaAdministratorId: UUID,
hrReviewerId: UUID,
durationMinutes: Long,
leaveType: String,
): StartedProcess {
val process = runtimeService.createProcessInstanceBuilder()
.processDefinitionKey(PROCESS_DEFINITION_KEY)
.processDefinitionKey(processDefinitionKey)
.businessKey(leaveRequestId.toString())
.variables(
mapOf(
@@ -96,6 +109,21 @@ class FlowableLeaveWorkflowGateway(
runtimeService.deleteProcessInstance(processInstanceId, reason)
}
override fun getProgress(processInstanceId: String): WorkflowProgress {
val active = taskService.createTaskQuery()
.processInstanceId(processInstanceId).active().list()
.map { it.name }.distinct()
val completed = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(processInstanceId).finished().orderByHistoricTaskInstanceEndTime().asc().list()
.map { it.name }.distinct()
return WorkflowProgress(
activeTaskNames = active,
completedTaskNames = completed,
processEnded = runtimeService.createProcessInstanceQuery()
.processInstanceId(processInstanceId).singleResult() == null,
)
}
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
id = task.id,
name = task.name,
@@ -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)!!,
) }
}
@@ -44,3 +44,15 @@ flowable:
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
async-executor-activate: false
history-level: audit
aioa:
ai-service:
url: ${AI_SERVICE_URL:http://127.0.0.1:8000}
object-storage:
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
access-key: ${MINIO_ROOT_USER:minioadmin}
secret-key: ${MINIO_ROOT_PASSWORD:change-me-now}
bucket: ${MINIO_BUCKET:aioa-attachments}
push:
firebase-credentials-file: ${FIREBASE_CREDENTIALS_FILE:}
dispatch-interval-ms: ${PUSH_DISPATCH_INTERVAL_MS:5000}
@@ -0,0 +1,23 @@
CREATE SCHEMA IF NOT EXISTS communication;
CREATE TABLE communication.notification (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
recipient_id UUID NOT NULL,
type VARCHAR(64) NOT NULL,
title VARCHAR(200) NOT NULL,
body VARCHAR(1000) NOT NULL,
resource_type VARCHAR(64),
resource_id VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMPTZ,
CONSTRAINT fk_notification_recipient FOREIGN KEY (tenant_id, recipient_id)
REFERENCES identity.user_account(tenant_id, id)
);
CREATE INDEX idx_notification_recipient_created
ON communication.notification (tenant_id, recipient_id, created_at DESC, id DESC);
CREATE INDEX idx_notification_recipient_unread
ON communication.notification (tenant_id, recipient_id)
WHERE read_at IS NULL;
@@ -0,0 +1,19 @@
CREATE TABLE identity.user_device (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
platform VARCHAR(32) NOT NULL,
app_version VARCHAR(64),
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
registered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, user_id, id),
CONSTRAINT fk_user_device_user FOREIGN KEY (tenant_id, user_id)
REFERENCES identity.user_account(tenant_id, id),
CONSTRAINT ck_user_device_status CHECK (status IN ('ACTIVE', 'REVOKED')),
CONSTRAINT ck_user_device_platform CHECK (platform IN ('IOS', 'ANDROID', 'OTHER'))
);
CREATE INDEX idx_user_device_owner ON identity.user_device (tenant_id, user_id, last_seen_at DESC);
@@ -0,0 +1,21 @@
ALTER TABLE identity.user_device
ADD COLUMN push_token VARCHAR(4096),
ADD COLUMN push_token_updated_at TIMESTAMPTZ;
CREATE UNIQUE INDEX uq_user_device_push_token
ON identity.user_device (push_token)
WHERE push_token IS NOT NULL AND status = 'ACTIVE';
CREATE TABLE communication.notification_push_outbox (
notification_id UUID PRIMARY KEY REFERENCES communication.notification(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error VARCHAR(500),
delivered_at TIMESTAMPTZ,
CONSTRAINT ck_push_outbox_status CHECK (status IN ('PENDING', 'DELIVERED'))
);
CREATE INDEX idx_push_outbox_pending
ON communication.notification_push_outbox (next_attempt_at, notification_id)
WHERE status = 'PENDING';
@@ -0,0 +1,58 @@
CREATE TABLE form.definition (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
form_key VARCHAR(100) NOT NULL,
version INTEGER NOT NULL,
status VARCHAR(32) NOT NULL,
data_schema JSONB NOT NULL,
ui_schema JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, form_key, version),
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
);
CREATE UNIQUE INDEX uq_form_definition_published
ON form.definition (tenant_id, form_key)
WHERE status = 'PUBLISHED';
CREATE TABLE workflow.process_binding (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
business_type VARCHAR(100) NOT NULL,
form_key VARCHAR(100) NOT NULL,
form_version INTEGER NOT NULL,
process_definition_key VARCHAR(100) NOT NULL,
leave_type VARCHAR(32),
min_duration_minutes BIGINT,
max_duration_minutes BIGINT,
priority INTEGER NOT NULL DEFAULT 100,
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
REFERENCES form.definition(tenant_id, form_key, version),
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
CONSTRAINT ck_process_binding_duration CHECK (
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
)
);
CREATE INDEX idx_process_binding_route
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
VALUES (
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
CURRENT_TIMESTAMP
);
INSERT INTO workflow.process_binding (
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
) VALUES (
'70000000-0000-7000-8000-000000000001',
'00000000-0000-7000-8000-000000000001',
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
);
@@ -0,0 +1,18 @@
CREATE TABLE workflow.process_template (
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
process_definition_key VARCHAR(64) NOT NULL,
version INTEGER NOT NULL CHECK (version > 0),
process_definition_id VARCHAR(255) NOT NULL,
deployment_id VARCHAR(255) NOT NULL,
name VARCHAR(100) NOT NULL,
mode VARCHAR(20) NOT NULL CHECK (mode IN ('SERIAL', 'PARALLEL', 'CONDITIONAL')),
template_spec JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'DEPLOYED' CHECK (status IN ('DEPLOYED', 'SUSPENDED')),
created_by UUID NOT NULL REFERENCES identity.user_account(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (tenant_id, process_definition_key, version),
UNIQUE (process_definition_id)
);
CREATE INDEX idx_process_template_tenant_created
ON workflow.process_template (tenant_id, created_at DESC);
@@ -0,0 +1,23 @@
CREATE TABLE business.leave_attachment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
leave_request_id UUID NOT NULL,
uploader_id UUID NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
object_key VARCHAR(1024) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
CONSTRAINT fk_leave_attachment_request FOREIGN KEY (tenant_id, leave_request_id)
REFERENCES business.leave_request(tenant_id, id) ON DELETE CASCADE,
CONSTRAINT fk_leave_attachment_uploader FOREIGN KEY (tenant_id, uploader_id)
REFERENCES identity.user_account(tenant_id, id),
CONSTRAINT ck_leave_attachment_status CHECK (status IN ('PENDING', 'READY')),
CONSTRAINT ck_leave_attachment_size CHECK (size_bytes > 0 AND size_bytes <= 10485760),
UNIQUE (tenant_id, object_key)
);
CREATE INDEX idx_leave_attachment_request
ON business.leave_attachment (tenant_id, leave_request_id, created_at, id);
@@ -0,0 +1,15 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class ApprovalRuleCatalogTest {
@Test fun `catalog exposes only supported runtime variables and safe empty policy`() {
val catalog=approvalRuleCatalog()
assertEquals(setOf("approverId","oaAdministratorId","hrReviewerId"),catalog.map{it.variable}.toSet())
assertTrue(catalog.all{it.emptyPolicy=="REJECT_SUBMISSION"})
assertTrue(catalog.any{it.sourceType=="POSITION"&&it.scope=="APPLICANT_DEPARTMENT"})
assertTrue(catalog.any{it.sourceType=="ROLE"&&it.scope=="TENANT"})
}
}
@@ -0,0 +1,19 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class ProcessConfigurationValidationTest {
@Test fun `accepts open and ordered duration ranges`() {
assertTrue(isValidDurationRange(null,null))
assertTrue(isValidDurationRange(0,480))
assertTrue(isValidDurationRange(1440,null))
}
@Test fun `rejects negative or reversed duration ranges`() {
assertFalse(isValidDurationRange(-1,480))
assertFalse(isValidDurationRange(960,480))
assertFalse(isValidDurationRange(0,-1))
}
}
@@ -0,0 +1,65 @@
package com.all8ai.aioa.admin.configuration
import org.flowable.engine.ProcessEngine
import org.flowable.engine.ProcessEngineConfiguration
import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.BeforeEach
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class ProcessTemplateExecutionTest {
private lateinit var engine:ProcessEngine
private val manager=ApprovalStepCommand("主管审批","approverId")
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
private val hr=ApprovalStepCommand("HR 复核","hrReviewerId")
private val variables=mapOf(
"approverId" to "manager", "oaAdministratorId" to "oa", "hrReviewerId" to "hr",
"businessId" to "00000000-0000-7000-8000-000000000001",
)
@BeforeEach fun startEngine() {
engine=StandaloneInMemProcessEngineConfiguration()
.setJdbcUrl("jdbc:h2:mem:aioa-${System.nanoTime()};DB_CLOSE_DELAY=-1")
.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE)
.buildProcessEngine()
}
@AfterEach fun stopEngine(){engine.close()}
@Test fun `conditional template skips or creates second approval by duration`() {
deploy(ProcessTemplateCommand("conditionalRuntime","条件流程","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
val short=start("conditionalRuntime",variables+mapOf("durationMinutes" to 480L))
completeOnlyTask(short,"manager",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(short).singleResult())
val long=start("conditionalRuntime",variables+mapOf("durationMinutes" to 1440L))
completeOnlyTask(long,"manager",true)
assertNotNull(engine.taskService.createTaskQuery().processInstanceId(long).taskAssignee("oa").singleResult())
completeOnlyTask(long,"oa",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(long).singleResult())
}
@Test fun `parallel template waits for every approval and terminates on rejection`() {
deploy(ProcessTemplateCommand("parallelRuntime","并行流程","PARALLEL",listOf(oa,hr)))
val approved=start("parallelRuntime",variables)
assertEquals(2,engine.taskService.createTaskQuery().processInstanceId(approved).count())
completeOnlyTask(approved,"oa",true)
assertNotNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
completeOnlyTask(approved,"hr",true)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
val rejected=start("parallelRuntime",variables)
completeOnlyTask(rejected,"oa",false)
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(rejected).singleResult())
assertEquals(0,engine.taskService.createTaskQuery().processInstanceId(rejected).count())
}
private fun deploy(command:ProcessTemplateCommand){engine.repositoryService.createDeployment().addString("${command.key}.bpmn20.xml",generateProcessTemplate(command)).deploy()}
private fun start(key:String,vars:Map<String,Any>)=engine.runtimeService.startProcessInstanceByKey(key,vars).id
private fun completeOnlyTask(instanceId:String,assignee:String,approved:Boolean){
val task=engine.taskService.createTaskQuery().processInstanceId(instanceId).taskAssignee(assignee).singleResult()
assertNotNull(task);engine.taskService.complete(task.id,mapOf("approved" to approved))
}
}
@@ -0,0 +1,30 @@
package com.all8ai.aioa.admin.configuration
import kotlin.test.Test
import kotlin.test.assertContains
import kotlin.test.assertFails
class ProcessTemplateGeneratorTest {
private val manager=ApprovalStepCommand("主管审批","approverId")
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
@Test fun `generates serial approval with rejection path`() {
val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
assertContains(xml,"targetRef=\"rejectedEnd\"")
}
@Test fun `generates parallel gateways and condition expressions`() {
val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
assertContains(conditional,"durationMinutes <= 960")
}
@Test fun `rejects unsafe identifiers and assignee expressions`() {
assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
assertFails { generateProcessTemplate(ProcessTemplateCommand("duplicateParallel","测试","PARALLEL",listOf(manager,manager))) }
}
}
@@ -0,0 +1,49 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.LeaveDraftSuggestion
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.audit.application.AuditService
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 org.mockito.Mockito.mock
import org.springframework.http.HttpStatus
import java.time.Instant
import java.util.UUID
class AiLeaveDraftServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
@Test
fun `returns suggestion that still requires user confirmation`() {
val gateway = AiLeaveDraftGateway {
_, _ -> SuggestedLeaveDraft(
LeaveDraftSuggestion("PERSONAL", Instant.parse("2026-07-19T05:30:00Z"),
Instant.parse("2026-07-19T09:30:00Z"), "办理个人事务", emptyList(), emptyList()),
"qwen-plus", true,
)
}
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
val result = service.suggest(actor, "明天下午请事假四小时", "Asia/Shanghai")
assertThat(result.suggestion.type).isEqualTo("PERSONAL")
assertThat(result.requiresUserConfirmation).isTrue()
}
@Test
fun `rejects model response that could bypass confirmation`() {
val gateway = AiLeaveDraftGateway { _, _ ->
SuggestedLeaveDraft(LeaveDraftSuggestion(null, null, null, null, emptyList(), emptyList()), "qwen-plus", false)
}
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
assertThatThrownBy { service.suggest(actor, "请假", "Asia/Shanghai") }
.isInstanceOfSatisfying(ApiException::class.java) {
assertThat(it.status).isEqualTo(HttpStatus.BAD_GATEWAY)
}
}
}

Some files were not shown because too many files have changed in this diff Show More