Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 75bc867e4c | |||
| 57c8a26147 | |||
| a2238bc853 | |||
| ca42abea74 | |||
| 34a279721e | |||
| cd72ec3756 | |||
| 9c366cc872 | |||
| 89c44a61a7 | |||
| 342a1fcb33 | |||
| 15abd8a32f | |||
| 24101398d8 | |||
| e19bdb8728 | |||
| b311be6aa6 | |||
| d0a2f4f923 | |||
| 090a7e33ce |
@@ -9,3 +9,9 @@ KEYCLOAK_ADMIN=admin
|
|||||||
KEYCLOAK_ADMIN_PASSWORD=change-me
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
MINIO_ROOT_USER=minioadmin
|
MINIO_ROOT_USER=minioadmin
|
||||||
MINIO_ROOT_PASSWORD=change-me-now
|
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
@@ -24,8 +24,19 @@ __pycache__/
|
|||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
.mypy_cache/
|
.mypy_cache/
|
||||||
.ruff_cache/
|
.ruff_cache/
|
||||||
|
*.egg-info/
|
||||||
|
|
||||||
|
# Deployment credentials
|
||||||
|
firebase-service-account*.json
|
||||||
|
**/firebase-service-account*.json
|
||||||
|
|
||||||
# Local data
|
# Local data
|
||||||
.local/
|
.local/
|
||||||
coverage/
|
coverage/
|
||||||
reports/
|
reports/
|
||||||
|
node_modules/
|
||||||
|
admin-web/dist/
|
||||||
|
*.tsbuildinfo
|
||||||
|
admin-web/vite.config.js
|
||||||
|
admin-web/vite.config.d.ts
|
||||||
|
backend/boot/bin/
|
||||||
|
|||||||
+141
@@ -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
|
||||||
@@ -19,6 +19,7 @@ AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实
|
|||||||
```text
|
```text
|
||||||
backend/ Kotlin + Spring Boot 模块化单体
|
backend/ Kotlin + Spring Boot 模块化单体
|
||||||
mobile/ Flutter 移动客户端
|
mobile/ Flutter 移动客户端
|
||||||
|
admin-web/ React + TypeScript 表单设计与流程配置管理端
|
||||||
ai-service/ Python AI 服务
|
ai-service/ Python AI 服务
|
||||||
contracts/ OpenAPI 与事件契约
|
contracts/ OpenAPI 与事件契约
|
||||||
deploy/ 本地与部署配置
|
deploy/ 本地与部署配置
|
||||||
@@ -38,6 +39,14 @@ scripts/ 开发辅助脚本
|
|||||||
|
|
||||||
本地 PostgreSQL 暴露在 `127.0.0.1:15432`,避免与系统或其他项目常用的 `5432` 端口冲突。
|
本地 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
|
```bash
|
||||||
@@ -49,4 +58,36 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
|||||||
|
|
||||||
当前实施范围和验收标准见 [docs/product/mvp.md](docs/product/mvp.md)。
|
当前实施范围和验收标准见 [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)。
|
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。
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# AIOA 管理设计中心
|
||||||
|
|
||||||
|
React + TypeScript 管理端,当前提供两个配置工作台:
|
||||||
|
|
||||||
|
- 可视化表单设计器:字段添加与排序、属性配置、移动端卡片预览、Schema 生成、草稿保存和版本发布。
|
||||||
|
- 流程选择与绑定:选择已发布表单和 Flowable 定义,配置业务类型、请假类型、时长范围、优先级以及绑定启停。
|
||||||
|
- 可视化流程设计器:使用受约束模板组合串行审批、并行会签和条件审批,校验后直接部署为新的 Flowable 流程版本。
|
||||||
|
|
||||||
|
流程绑定仅负责从权威业务数据选择已经部署的流程定义;客户端不能指定流程,已启动实例也不会因绑定变化而切换流程版本。
|
||||||
|
|
||||||
|
## 本地运行
|
||||||
|
|
||||||
|
先从仓库根目录启动 PostgreSQL、Keycloak 和后端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --env-file .env.example -f deploy/compose/compose.yaml up -d postgres keycloak
|
||||||
|
|
||||||
|
cd backend
|
||||||
|
export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home
|
||||||
|
export GRADLE_USER_HOME=/tmp/aioa-gradle-home
|
||||||
|
./gradlew --no-daemon :boot:bootRun
|
||||||
|
```
|
||||||
|
|
||||||
|
再启动管理端:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd admin-web
|
||||||
|
npm ci
|
||||||
|
npm run dev -- --host 127.0.0.1
|
||||||
|
```
|
||||||
|
|
||||||
|
访问 `http://127.0.0.1:5173`,通过 Keycloak 登录。默认 OIDC Issuer 为 `http://localhost:8081/realms/aioa`,后端 API 为 `http://localhost:8080/api/v1`;需要覆盖时可设置:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VITE_OIDC_ISSUER=http://localhost:8081/realms/aioa
|
||||||
|
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||||
|
```
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak Realm 首次导入时会创建 `aioa-admin-web` 公共客户端并启用 Authorization Code + PKCE S256。若本机 Keycloak 已在加入该客户端之前启动,需要在开发环境重建 Keycloak 容器或通过管理控制台补入同名客户端。
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<div id="root"></div><script type="module" src="/src/main.tsx"></script>
|
||||||
Generated
+2959
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"name": "aioa-admin-web",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"test": "vitest run"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"oidc-client-ts": "^3.3.0",
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@testing-library/react": "^16.3.0",
|
||||||
|
"@types/react": "^19.1.10",
|
||||||
|
"@types/react-dom": "^19.1.7",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"jsdom": "^26.1.0",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^7.1.7",
|
||||||
|
"vitest": "^3.2.4"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,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,
|
||||||
|
}));
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { api } from './api';
|
||||||
|
|
||||||
|
type FormVersion = { form_key: string; version: number; status: string };
|
||||||
|
type ProcessDefinition = { id: string; key: string; name?: string; version: number; suspended: boolean };
|
||||||
|
type Binding = {
|
||||||
|
id: string; business_type: string; form_key: string; form_version: number;
|
||||||
|
process_definition_key: string; leave_type?: string; min_duration_minutes?: number;
|
||||||
|
max_duration_minutes?: number; priority: number; status: 'ACTIVE' | 'INACTIVE';
|
||||||
|
};
|
||||||
|
|
||||||
|
const leaveTypes = [{ value: '', label: '全部请假类型' }, { value: 'PERSONAL', label: '事假' }, { value: 'SICK', label: '病假' }, { value: 'ANNUAL', label: '年假' }];
|
||||||
|
const minutesToDays = (value?: number) => value == null ? '不限' : `${Number((value / 480).toFixed(1))} 天`;
|
||||||
|
|
||||||
|
export function ProcessBindings() {
|
||||||
|
const [forms, setForms] = useState<FormVersion[]>([]);
|
||||||
|
const [definitions, setDefinitions] = useState<ProcessDefinition[]>([]);
|
||||||
|
const [bindings, setBindings] = useState<Binding[]>([]);
|
||||||
|
const [message, setMessage] = useState('');
|
||||||
|
const [formKey, setFormKey] = useState('leave-request');
|
||||||
|
const published = forms.filter(form => form.status === 'PUBLISHED');
|
||||||
|
const selectedVersions = published.filter(form => form.form_key === formKey);
|
||||||
|
const [formVersion, setFormVersion] = useState(1);
|
||||||
|
const [processKey, setProcessKey] = useState('leaveApproval');
|
||||||
|
const [leaveType, setLeaveType] = useState('');
|
||||||
|
const [minDays, setMinDays] = useState('');
|
||||||
|
const [maxDays, setMaxDays] = useState('');
|
||||||
|
const [priority, setPriority] = useState(100);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const [formResult, definitionResult, bindingResult] = await Promise.all([
|
||||||
|
api<FormVersion[]>('/admin/process-configuration/forms'),
|
||||||
|
api<ProcessDefinition[]>('/admin/workflows/definitions'),
|
||||||
|
api<Binding[]>('/admin/process-configuration/bindings'),
|
||||||
|
]);
|
||||||
|
setForms(formResult); setDefinitions(definitionResult); setBindings(bindingResult);
|
||||||
|
const firstPublished = formResult.find(form => form.status === 'PUBLISHED');
|
||||||
|
if (firstPublished) { setFormKey(firstPublished.form_key); setFormVersion(firstPublished.version); }
|
||||||
|
const firstDefinition = definitionResult.find(definition => !definition.suspended);
|
||||||
|
if (firstDefinition) setProcessKey(firstDefinition.key);
|
||||||
|
} catch (error) { setMessage(error instanceof Error ? error.message : '配置加载失败'); }
|
||||||
|
}
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
useEffect(() => { if (selectedVersions.length && !selectedVersions.some(form => form.version === formVersion)) setFormVersion(selectedVersions[0].version); }, [formKey, forms]);
|
||||||
|
|
||||||
|
const ruleSummary = useMemo(() => {
|
||||||
|
const type = leaveTypes.find(item => item.value === leaveType)?.label ?? leaveType;
|
||||||
|
return `${type} · ${minDays || '0'}–${maxDays || '∞'} 天 · 优先级 ${priority}`;
|
||||||
|
}, [leaveType, minDays, maxDays, priority]);
|
||||||
|
|
||||||
|
async function createBinding() {
|
||||||
|
try {
|
||||||
|
await api('/admin/process-configuration/bindings', { method: 'POST', body: JSON.stringify({
|
||||||
|
businessType: 'LEAVE_REQUEST', formKey, formVersion, processDefinitionKey: processKey,
|
||||||
|
leaveType: leaveType || null,
|
||||||
|
minDurationMinutes: minDays === '' ? null : Math.round(Number(minDays) * 480),
|
||||||
|
maxDurationMinutes: maxDays === '' ? null : Math.round(Number(maxDays) * 480), priority,
|
||||||
|
}) });
|
||||||
|
setMessage('流程绑定已启用'); await load();
|
||||||
|
} catch (error) { setMessage(error instanceof Error ? error.message : '绑定创建失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggle(binding: Binding) {
|
||||||
|
try {
|
||||||
|
const status = binding.status === 'ACTIVE' ? 'INACTIVE' : 'ACTIVE';
|
||||||
|
await api(`/admin/process-configuration/bindings/${binding.id}/status`, { method: 'PUT', body: JSON.stringify({ status }) });
|
||||||
|
setMessage(status === 'ACTIVE' ? '绑定已启用' : '绑定已停用'); await load();
|
||||||
|
} catch (error) { setMessage(error instanceof Error ? error.message : '状态修改失败'); }
|
||||||
|
}
|
||||||
|
|
||||||
|
return <section className="bindingPage">
|
||||||
|
{message && <div className="toast" onClick={() => setMessage('')}>{message}</div>}
|
||||||
|
<div className="bindingIntro"><div><h1>流程选择与绑定</h1><p>客户端只提交业务数据,服务端按类型、时长和优先级选择 Flowable 流程。</p></div><span className="safeBadge">服务端权威路由</span></div>
|
||||||
|
<div className="bindingGrid">
|
||||||
|
<div className="configCard"><h2>新建路由规则</h2>
|
||||||
|
<label>业务类型<input value="请假申请" disabled /></label>
|
||||||
|
<div className="twoColumns"><label>表单<select value={formKey} onChange={e => setFormKey(e.target.value)}>{[...new Set(published.map(form => form.form_key))].map(key => <option key={key}>{key}</option>)}</select></label><label>版本<select value={formVersion} onChange={e => setFormVersion(Number(e.target.value))}>{selectedVersions.map(form => <option key={form.version} value={form.version}>v{form.version} · 已发布</option>)}</select></label></div>
|
||||||
|
<label>Flowable 流程<select value={processKey} onChange={e => setProcessKey(e.target.value)}>{definitions.filter(definition => !definition.suspended).map(definition => <option key={definition.id} value={definition.key}>{definition.name || definition.key} · v{definition.version}</option>)}</select></label>
|
||||||
|
<label>请假类型<select value={leaveType} onChange={e => setLeaveType(e.target.value)}>{leaveTypes.map(type => <option key={type.value} value={type.value}>{type.label}</option>)}</select></label>
|
||||||
|
<div className="twoColumns"><label>最少天数<input type="number" min="0" step="0.5" value={minDays} onChange={e => setMinDays(e.target.value)} placeholder="不限" /></label><label>最多天数<input type="number" min="0" step="0.5" value={maxDays} onChange={e => setMaxDays(e.target.value)} placeholder="不限" /></label></div>
|
||||||
|
<label>优先级<input type="number" value={priority} onChange={e => setPriority(Number(e.target.value))} /></label>
|
||||||
|
<div className="rulePreview"><strong>匹配规则</strong><span>{ruleSummary}</span></div>
|
||||||
|
<button className="primaryWide" onClick={createBinding} disabled={!formKey || !processKey}>创建并启用绑定</button>
|
||||||
|
</div>
|
||||||
|
<div className="flowCard"><h2>运行时选择</h2><div className="flowPreview"><div className="flowNode source">业务提交<span>LEAVE_REQUEST</span></div><i>→</i><div className="flowNode decision">规则匹配<span>类型 · 时长 · 优先级</span></div><i>→</i><div className="flowNode target">启动流程<span>{processKey || '请选择流程'}</span></div></div><div className="guardrails"><p>✓ 仅引用已发布表单</p><p>✓ 优先级高的精确规则先匹配</p><p>✓ 无匹配规则时拒绝启动流程</p><p>✓ 已运行实例不会随绑定变更</p></div></div>
|
||||||
|
</div>
|
||||||
|
<div className="bindingList"><h2>现有绑定</h2>{bindings.length === 0 ? <p className="empty">暂无绑定</p> : bindings.map(binding => <article key={binding.id} className={binding.status === 'ACTIVE' ? '' : 'inactive'}><div><strong>{binding.form_key} · v{binding.form_version}</strong><span>{binding.business_type} → {binding.process_definition_key}</span></div><div className="conditions"><span>{binding.leave_type || '全部类型'}</span><span>{minutesToDays(binding.min_duration_minutes)} – {minutesToDays(binding.max_duration_minutes)}</span><span>P{binding.priority}</span></div><button className={binding.status === 'ACTIVE' ? 'dangerGhost' : ''} onClick={() => toggle(binding)}>{binding.status === 'ACTIVE' ? '停用' : '启用'}</button></article>)}</div>
|
||||||
|
</section>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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>; }
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { accessToken } from './auth';
|
||||||
|
|
||||||
|
const baseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api/v1';
|
||||||
|
const deviceId = localStorage.getItem('aioa.admin.device') ?? crypto.randomUUID();
|
||||||
|
localStorage.setItem('aioa.admin.device', deviceId);
|
||||||
|
let registeredToken = '';
|
||||||
|
|
||||||
|
async function ensureDevice(token: string) {
|
||||||
|
if (registeredToken === token) return;
|
||||||
|
const response = await fetch(`${baseUrl}/devices/register`, {
|
||||||
|
method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ id: deviceId, name: navigator.userAgent.slice(0, 200), platform: 'OTHER', appVersion: 'admin-web' }),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`设备注册失败 (${response.status})`);
|
||||||
|
registeredToken = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||||
|
const token = await accessToken();
|
||||||
|
if (!token) throw new Error('登录已失效');
|
||||||
|
await ensureDevice(token);
|
||||||
|
const response = await fetch(`${baseUrl}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: { Authorization: `Bearer ${token}`, 'X-AIOA-Device-Id': deviceId, 'Content-Type': 'application/json', ...init.headers },
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error((await response.json().catch(() => null))?.detail ?? `请求失败 (${response.status})`);
|
||||||
|
if (response.status === 204 || response.headers.get('content-length') === '0') return undefined as T;
|
||||||
|
const text = await response.text();
|
||||||
|
return (text ? JSON.parse(text) : undefined) as T;
|
||||||
|
}
|
||||||
@@ -0,0 +1,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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { createRoot } from 'react-dom/client';
|
||||||
|
import { App } from './App';
|
||||||
|
import './styles.css';
|
||||||
|
createRoot(document.getElementById('root')!).render(<React.StrictMode><App /></React.StrictMode>);
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { buildSchemas } from './schema';
|
||||||
|
describe('buildSchemas', () => {
|
||||||
|
it('builds required fields and safe controls', () => {
|
||||||
|
const result = buildSchemas('请假申请', [{ id: '1', key: 'reason', label: '原因', control: 'textArea', required: true }]);
|
||||||
|
expect(result.dataSchema.required).toEqual(['reason']);
|
||||||
|
expect(result.uiSchema.sections[0].controls[0].control).toBe('textArea');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,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
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022", "useDefineForClassFields": true, "lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"allowJs": false, "skipLibCheck": true, "esModuleInterop": true, "allowSyntheticDefaultImports": true,
|
||||||
|
"strict": true, "forceConsistentCasingInFileNames": true, "module": "ESNext", "moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true, "isolatedModules": true, "noEmit": true, "jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": { "composite": true, "skipLibCheck": true, "module": "ESNext", "moduleResolution": "Bundler", "allowImportingTsExtensions": true, "noEmit": true },
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { defineConfig } from 'vitest/config';
|
||||||
|
import react from '@vitejs/plugin-react';
|
||||||
|
export default defineConfig({ plugins: [react()], test: { environment: 'jsdom' }, server: { port: 5173 } });
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
.pytest_cache
|
||||||
|
tests
|
||||||
@@ -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
@@ -1,3 +1,14 @@
|
|||||||
# AI Service
|
# 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 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
"""AIOA AI service."""
|
||||||
@@ -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()
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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
|
||||||
@@ -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(中文)、control(text/textArea/select/dateTime/number)、required、placeholder、options(仅 select)、helperText
|
||||||
|
- process:对象,含 mode(SERIAL/PARALLEL/CONDITIONAL)、steps(数组,每项含 name 和 assigneeVariable)、conditionThresholdDays(仅 CONDITIONAL)
|
||||||
|
- summary:一句话总结
|
||||||
|
- assumptions:你做出的假设
|
||||||
|
- needsClarification:需要用户回答的问题(CLARIFYING 阶段使用)
|
||||||
|
- validationIssues:校验发现的问题数组,每项含 field、issue、severity(ERROR/WARNING)
|
||||||
|
- schemaReady:Schema 是否已确认可用(仅 CONFIRMING 阶段用户确认后为 true)
|
||||||
|
|
||||||
|
## 约束
|
||||||
|
|
||||||
|
- assigneeVariable 只能是:approverId(部门主管)、oaAdministratorId(OA 管理员)、hrReviewerId(HR 复核人)
|
||||||
|
- 字段 key 用英文 camelCase 且唯一,label 用中文
|
||||||
|
- 审批流程至少 2 个步骤
|
||||||
|
- PARALLEL 和 CONDITIONAL 模式下 assigneeVariable 不可重复
|
||||||
|
- CONDITIONAL 模式需设置 conditionThresholdDays
|
||||||
|
- 不得输出权限、租户、隐藏字段等敏感信息
|
||||||
|
- 不得使用 Markdown
|
||||||
|
- 如果用户提供了 currentSchema,说明用户已在图形界面修改过,你需要基于当前 Schema 进行调整而非重新生成
|
||||||
|
""".strip()
|
||||||
|
|
||||||
|
|
||||||
|
class QwenConfigurationError(RuntimeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class QwenUpstreamError(RuntimeError):
|
||||||
|
pass
|
||||||
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -28,11 +28,14 @@ dependencies {
|
|||||||
implementation("org.flywaydb:flyway-database-postgresql")
|
implementation("org.flywaydb:flyway-database-postgresql")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
|
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")
|
runtimeOnly("org.postgresql:postgresql")
|
||||||
|
|
||||||
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
testImplementation("org.springframework.boot:spring-boot-starter-test")
|
||||||
testImplementation("org.springframework.security:spring-security-test")
|
testImplementation("org.springframework.security:spring-security-test")
|
||||||
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
|
||||||
|
testRuntimeOnly("com.h2database:h2")
|
||||||
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
testRuntimeOnly("org.junit.platform:junit-platform-launcher")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package com.all8ai.aioa
|
|||||||
|
|
||||||
import org.springframework.boot.autoconfigure.SpringBootApplication
|
import org.springframework.boot.autoconfigure.SpringBootApplication
|
||||||
import org.springframework.boot.runApplication
|
import org.springframework.boot.runApplication
|
||||||
|
import org.springframework.scheduling.annotation.EnableScheduling
|
||||||
|
|
||||||
@SpringBootApplication
|
@SpringBootApplication
|
||||||
|
@EnableScheduling
|
||||||
class AioaApplication
|
class AioaApplication
|
||||||
|
|
||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
|
|||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
data class ApprovalRuleOption(
|
||||||
|
val variable:String,val label:String,val sourceType:String,val selector:String,
|
||||||
|
val scope:String,val emptyPolicy:String,val description:String,
|
||||||
|
)
|
||||||
|
|
||||||
|
fun approvalRuleCatalog()=listOf(
|
||||||
|
ApprovalRuleOption(
|
||||||
|
"approverId","发起人所在部门主管","POSITION","manager","APPLICANT_DEPARTMENT","REJECT_SUBMISSION",
|
||||||
|
"根据发起人的有效主任职定位部门,再选择该部门有效的 manager 岗位人员。",
|
||||||
|
),
|
||||||
|
ApprovalRuleOption(
|
||||||
|
"oaAdministratorId","租户 OA 管理员","ROLE","oa_admin","TENANT","REJECT_SUBMISSION",
|
||||||
|
"在当前租户内选择拥有有效 oa_admin 角色且账号状态正常的人员。",
|
||||||
|
),
|
||||||
|
ApprovalRuleOption(
|
||||||
|
"hrReviewerId","租户 HR 复核人","ROLE","hr_reviewer","TENANT","REJECT_SUBMISSION",
|
||||||
|
"在当前租户内选择拥有有效 hr_reviewer 角色且账号状态正常的人员。",
|
||||||
|
),
|
||||||
|
)
|
||||||
+74
@@ -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)
|
||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
|
||||||
|
data class ApprovalStepCommand(val name:String,val assigneeVariable:String)
|
||||||
|
data class ProcessTemplateCommand(
|
||||||
|
val key:String,val name:String,val mode:String,val steps:List<ApprovalStepCommand>,
|
||||||
|
val conditionVariable:String="durationMinutes",val conditionThreshold:Long=1440,
|
||||||
|
)
|
||||||
|
|
||||||
|
private val safeKey=Regex("[A-Za-z][A-Za-z0-9_-]{2,63}")
|
||||||
|
private val allowedAssignees=setOf("approverId","oaAdministratorId","hrReviewerId")
|
||||||
|
private val allowedConditions=setOf("durationMinutes")
|
||||||
|
|
||||||
|
fun generateProcessTemplate(command:ProcessTemplateCommand):String {
|
||||||
|
if(!safeKey.matches(command.key)) invalid("流程 Key 必须以字母开头且只能包含字母、数字、下划线或连字符")
|
||||||
|
if(command.name.isBlank()||command.name.length>100) invalid("流程名称长度无效")
|
||||||
|
if(command.mode !in setOf("SERIAL","PARALLEL","CONDITIONAL")) invalid("流程模式无效")
|
||||||
|
if(command.mode=="CONDITIONAL" && command.steps.size!=2 || command.mode!="CONDITIONAL" && command.steps.size !in 1..6) invalid("审批节点数量无效")
|
||||||
|
if(command.steps.any{it.name.isBlank()||it.name.length>80||it.assigneeVariable !in allowedAssignees}) invalid("审批节点配置无效")
|
||||||
|
if(command.mode in setOf("PARALLEL","CONDITIONAL") && command.steps.map{it.assigneeVariable}.distinct().size!=command.steps.size) invalid("并行或条件复核节点必须使用不同的审批人规则")
|
||||||
|
if(command.conditionVariable !in allowedConditions||command.conditionThreshold<0) invalid("条件配置无效")
|
||||||
|
val body=when(command.mode){
|
||||||
|
"SERIAL"->serial(command.steps)
|
||||||
|
"PARALLEL"->parallel(command.steps)
|
||||||
|
else->conditional(command.steps,command.conditionVariable,command.conditionThreshold)
|
||||||
|
}
|
||||||
|
return """<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="https://aioa.all8ai.com/designer">
|
||||||
|
<process id="${command.key}" name="${xml(command.name)}" isExecutable="true">
|
||||||
|
$body
|
||||||
|
</process>
|
||||||
|
</definitions>"""
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serial(steps:List<ApprovalStepCommand>):String=buildString {
|
||||||
|
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
|
||||||
|
steps.forEachIndexed { index,step ->
|
||||||
|
appendDecisionTask(index,step,if(index==steps.lastIndex) "approvedEnd" else "task${index+1}")
|
||||||
|
}
|
||||||
|
appendEnds()
|
||||||
|
}.trimEnd()
|
||||||
|
|
||||||
|
private fun parallel(steps:List<ApprovalStepCommand>):String=buildString {
|
||||||
|
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-start-split\" sourceRef=\"start\" targetRef=\"parallelSplit\"/>")
|
||||||
|
appendLine(" <parallelGateway id=\"parallelSplit\" name=\"并行会签\"/>")
|
||||||
|
steps.forEachIndexed { index,step ->
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-split-task$index\" sourceRef=\"parallelSplit\" targetRef=\"task$index\"/>")
|
||||||
|
appendDecisionTask(index,step,"parallelJoin")
|
||||||
|
}
|
||||||
|
appendLine(" <parallelGateway id=\"parallelJoin\" name=\"全部通过\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-join-end\" sourceRef=\"parallelJoin\" targetRef=\"approvedEnd\"/>")
|
||||||
|
appendEnds()
|
||||||
|
}.trimEnd()
|
||||||
|
|
||||||
|
private fun conditional(steps:List<ApprovalStepCommand>,variable:String,threshold:Long):String=buildString {
|
||||||
|
appendLine(" <startEvent id=\"start\" name=\"已提交\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-start-task0\" sourceRef=\"start\" targetRef=\"task0\"/>")
|
||||||
|
appendDecisionTask(0,steps[0],"routeDecision")
|
||||||
|
appendLine(" <exclusiveGateway id=\"routeDecision\" name=\"条件路由\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-condition-short\" sourceRef=\"routeDecision\" targetRef=\"approvedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable <= $threshold}]]></conditionExpression></sequenceFlow>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-condition-long\" sourceRef=\"routeDecision\" targetRef=\"task1\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${$variable > $threshold}]]></conditionExpression></sequenceFlow>")
|
||||||
|
appendDecisionTask(1,steps[1],"approvedEnd")
|
||||||
|
appendEnds()
|
||||||
|
}.trimEnd()
|
||||||
|
|
||||||
|
private fun StringBuilder.appendDecisionTask(index:Int,step:ApprovalStepCommand,approvedTarget:String) {
|
||||||
|
appendLine(" <userTask id=\"task$index\" name=\"${xml(step.name)}\" flowable:assignee=\"\${${step.assigneeVariable}}\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-task$index-decision\" sourceRef=\"task$index\" targetRef=\"decision$index\"/>")
|
||||||
|
appendLine(" <exclusiveGateway id=\"decision$index\" name=\"审批结果\"/>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-task$index-approved\" sourceRef=\"decision$index\" targetRef=\"$approvedTarget\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == true}]]></conditionExpression></sequenceFlow>")
|
||||||
|
appendLine(" <sequenceFlow id=\"flow-task$index-rejected\" sourceRef=\"decision$index\" targetRef=\"rejectedEnd\"><conditionExpression xsi:type=\"tFormalExpression\"><![CDATA[\${approved == false}]]></conditionExpression></sequenceFlow>")
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun StringBuilder.appendEnds(){
|
||||||
|
appendLine(" <endEvent id=\"approvedEnd\" name=\"已批准\"/>")
|
||||||
|
appendLine(" <endEvent id=\"rejectedEnd\" name=\"已驳回\"><terminateEventDefinition/></endEvent>")
|
||||||
|
}
|
||||||
|
private fun xml(value:String)=value.replace("&","&").replace("<","<").replace(">",">").replace("\"",""")
|
||||||
|
private fun invalid(message:String):Nothing=throw ApiException(HttpStatus.BAD_REQUEST,"PROCESS_TEMPLATE_INVALID",message)
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
package com.all8ai.aioa.admin.metrics
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.flowable.engine.RuntimeService
|
||||||
|
import org.flowable.engine.TaskService
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping
|
||||||
|
import org.springframework.web.bind.annotation.RestController
|
||||||
|
|
||||||
|
data class OperationsMetrics(
|
||||||
|
val leaveByStatus:Map<String,Int>,val activeWorkflowInstances:Long,val activeWorkflowTasks:Long,
|
||||||
|
val unreadNotifications:Int,val pendingPush:Int,val failedPushAttempts:Int,val activeDevices:Int,
|
||||||
|
)
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/metrics")
|
||||||
|
class OperationsMetricsController(private val users:CurrentUserService,private val dsl:DSLContext,private val runtime:RuntimeService,private val tasks:TaskService) {
|
||||||
|
@GetMapping fun metrics(@AuthenticationPrincipal jwt:Jwt):OperationsMetrics {
|
||||||
|
val actor=users.get(jwt.subject,jwt.getClaimAsString("tenant_id")); actor.requirePermission(ToolPermission.OPERATIONS_METRICS_READ_TENANT)
|
||||||
|
fun count(sql:String,vararg bindings:Any):Int=dsl.fetchOne(sql,*bindings)?.get("count",Int::class.java)?:0
|
||||||
|
val statuses=dsl.fetch("SELECT status,COUNT(*)::int count FROM business.leave_request WHERE tenant_id=? GROUP BY status",actor.tenantId)
|
||||||
|
.associate { it.get("status",String::class.java)!! to it.get("count",Int::class.java)!! }
|
||||||
|
return OperationsMetrics(statuses,
|
||||||
|
runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).count(),
|
||||||
|
tasks.createTaskQuery().processVariableValueEquals("tenantId",actor.tenantId.toString()).active().count(),
|
||||||
|
count("SELECT COUNT(*)::int count FROM communication.notification WHERE tenant_id=? AND read_at IS NULL",actor.tenantId),
|
||||||
|
count("SELECT COUNT(*)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=? AND o.status='PENDING'",actor.tenantId),
|
||||||
|
count("SELECT COALESCE(SUM(o.attempts),0)::int count FROM communication.notification_push_outbox o JOIN communication.notification n ON n.id=o.notification_id WHERE n.tenant_id=?",actor.tenantId),
|
||||||
|
count("SELECT COUNT(*)::int count FROM identity.user_device WHERE tenant_id=? AND status='ACTIVE'",actor.tenantId))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.all8ai.aioa.admin.organization
|
||||||
|
|
||||||
|
import com.all8ai.aioa.audit.application.AuditService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.id.UuidV7
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.http.HttpStatus
|
||||||
|
import org.springframework.stereotype.Service
|
||||||
|
import org.springframework.transaction.annotation.Transactional
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
data class AdminDepartment(val id: UUID, val code: String, val name: String, val status: String)
|
||||||
|
data class AdminRole(val id: UUID, val code: String, val name: String, val status: String)
|
||||||
|
data class AdminUser(val id: UUID, val username: String, val displayName: String, val email: String?, val status: String, val departmentId: UUID?, val positionId: UUID?, val roles: Set<String>)
|
||||||
|
|
||||||
|
@Service
|
||||||
|
class AdminOrganizationService(private val dsl: DSLContext, private val audit: AuditService) {
|
||||||
|
fun departments(actor: CurrentUser): List<AdminDepartment> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("SELECT id, code, name, status FROM organization.department WHERE tenant_id = ? ORDER BY code", actor.tenantId)
|
||||||
|
.map { AdminDepartment(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
|
||||||
|
}
|
||||||
|
fun roles(actor: CurrentUser): List<AdminRole> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("SELECT id, code, name, status FROM authz.role WHERE tenant_id = ? ORDER BY code", actor.tenantId)
|
||||||
|
.map { AdminRole(it.get("id", UUID::class.java)!!, it.get("code", String::class.java)!!, it.get("name", String::class.java)!!, it.get("status", String::class.java)!!) }
|
||||||
|
}
|
||||||
|
fun users(actor: CurrentUser): List<AdminUser> {
|
||||||
|
require(actor)
|
||||||
|
return dsl.fetch("""
|
||||||
|
SELECT u.id, u.username, u.display_name, u.email, u.status, a.department_id, a.position_id,
|
||||||
|
COALESCE(array_agg(r.code ORDER BY r.code) FILTER (WHERE r.code IS NOT NULL), '{}') AS roles
|
||||||
|
FROM identity.user_account u
|
||||||
|
LEFT JOIN organization.user_assignment a ON a.tenant_id=u.tenant_id AND a.user_id=u.id AND a.is_primary=TRUE AND a.effective_until IS NULL
|
||||||
|
LEFT JOIN authz.user_role ur ON ur.tenant_id=u.tenant_id AND ur.user_id=u.id AND ur.effective_until IS NULL
|
||||||
|
LEFT JOIN authz.role r ON r.tenant_id=ur.tenant_id AND r.id=ur.role_id
|
||||||
|
WHERE u.tenant_id=? GROUP BY u.id,a.department_id,a.position_id ORDER BY u.username
|
||||||
|
""".trimIndent(), actor.tenantId).map {
|
||||||
|
AdminUser(it.get("id", UUID::class.java)!!, it.get("username", String::class.java)!!, it.get("display_name", String::class.java)!!,
|
||||||
|
it.get("email", String::class.java), it.get("status", String::class.java)!!, it.get("department_id", UUID::class.java),
|
||||||
|
it.get("position_id", UUID::class.java), (it.get("roles") as? Array<*>)?.filterIsInstance<String>()?.toSet().orEmpty())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun createDepartment(actor: CurrentUser, code: String, name: String): AdminDepartment {
|
||||||
|
require(actor); val c = valid(code, 64); val n = valid(name, 200); val id = UuidV7.generate()
|
||||||
|
try { dsl.execute("INSERT INTO organization.department(id,tenant_id,code,name,status) VALUES(?,?,?,?,'ACTIVE')", id, actor.tenantId, c, n) }
|
||||||
|
catch (_: Exception) { throw ApiException(HttpStatus.CONFLICT, "DEPARTMENT_CODE_EXISTS", "部门编码已存在") }
|
||||||
|
audit.recordSuccess(actor, "DEPARTMENT_CREATED", "DEPARTMENT", id.toString(), null, mapOf("code" to c))
|
||||||
|
return AdminDepartment(id, c, n, "ACTIVE")
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun replaceRoles(actor: CurrentUser, userId: UUID, roleCodes: Set<String>) {
|
||||||
|
require(actor); if (roleCodes.isEmpty()) throw ApiException(HttpStatus.BAD_REQUEST, "ROLES_REQUIRED", "至少保留一个角色")
|
||||||
|
val bindings = mutableListOf<Any>(actor.tenantId).apply { addAll(roleCodes) }.toTypedArray()
|
||||||
|
val roleIds = dsl.fetch("SELECT id,code FROM authz.role WHERE tenant_id=? AND code IN (${roleCodes.joinToString { "?" }}) AND status='ACTIVE'", *bindings)
|
||||||
|
if (roleIds.size != roleCodes.size) throw ApiException(HttpStatus.BAD_REQUEST, "ROLE_INVALID", "包含不存在的角色")
|
||||||
|
if (dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM identity.user_account WHERE tenant_id=? AND id=?) AS ok", actor.tenantId, userId)?.get("ok", Boolean::class.java) != true) throw ApiException(HttpStatus.NOT_FOUND, "USER_NOT_FOUND", "用户不存在")
|
||||||
|
dsl.execute("DELETE FROM authz.user_role WHERE tenant_id=? AND user_id=?", actor.tenantId, userId)
|
||||||
|
roleIds.forEach { dsl.execute("INSERT INTO authz.user_role(tenant_id,user_id,role_id) VALUES(?,?,?)", actor.tenantId, userId, it.get("id", UUID::class.java)) }
|
||||||
|
audit.recordSuccess(actor, "USER_ROLES_REPLACED", "USER", userId.toString(), null, mapOf("roles" to roleCodes.sorted()))
|
||||||
|
}
|
||||||
|
@Transactional
|
||||||
|
fun assign(actor: CurrentUser, userId: UUID, departmentId: UUID, positionId: UUID) {
|
||||||
|
require(actor)
|
||||||
|
val validRefs = dsl.fetchOne("SELECT EXISTS(SELECT 1 FROM organization.department WHERE tenant_id=? AND id=? AND status='ACTIVE') AND EXISTS(SELECT 1 FROM organization.position WHERE tenant_id=? AND id=? AND status='ACTIVE') AS ok", actor.tenantId, departmentId, actor.tenantId, positionId)?.get("ok", Boolean::class.java) == true
|
||||||
|
if (!validRefs) throw ApiException(HttpStatus.BAD_REQUEST, "ASSIGNMENT_REFERENCE_INVALID", "部门或岗位无效")
|
||||||
|
dsl.execute("UPDATE organization.user_assignment SET effective_until=CURRENT_TIMESTAMP,is_primary=FALSE WHERE tenant_id=? AND user_id=? AND is_primary=TRUE AND effective_until IS NULL", actor.tenantId, userId)
|
||||||
|
dsl.execute("INSERT INTO organization.user_assignment(id,tenant_id,user_id,department_id,position_id,is_primary) VALUES(?,?,?,?,?,TRUE)", UuidV7.generate(), actor.tenantId, userId, departmentId, positionId)
|
||||||
|
audit.recordSuccess(actor, "USER_ASSIGNMENT_REPLACED", "USER", userId.toString(), null, mapOf("departmentId" to departmentId, "positionId" to positionId))
|
||||||
|
}
|
||||||
|
private fun require(actor: CurrentUser) = actor.requirePermission(ToolPermission.ORGANIZATION_MANAGE_TENANT)
|
||||||
|
private fun valid(value: String, max: Int) = value.trim().takeIf { it.isNotEmpty() && it.length <= max && it.matches(Regex("[A-Za-z0-9._-]+|[\\p{L}0-9 ._-]+")) } ?: throw ApiException(HttpStatus.BAD_REQUEST, "VALUE_INVALID", "输入值无效")
|
||||||
|
}
|
||||||
+24
@@ -0,0 +1,24 @@
|
|||||||
|
package com.all8ai.aioa.admin.organization
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import jakarta.validation.Valid
|
||||||
|
import jakarta.validation.constraints.NotBlank
|
||||||
|
import jakarta.validation.constraints.Size
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/organization")
|
||||||
|
class AdminOrganizationController(private val users: CurrentUserService, private val service: AdminOrganizationService) {
|
||||||
|
private fun actor(jwt: Jwt) = users.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||||
|
@GetMapping("/departments") fun departments(@AuthenticationPrincipal jwt: Jwt) = service.departments(actor(jwt))
|
||||||
|
@PostMapping("/departments") fun createDepartment(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody body: DepartmentCommand) = service.createDepartment(actor(jwt), body.code, body.name)
|
||||||
|
@GetMapping("/roles") fun roles(@AuthenticationPrincipal jwt: Jwt) = service.roles(actor(jwt))
|
||||||
|
@GetMapping("/users") fun listUsers(@AuthenticationPrincipal jwt: Jwt) = service.users(actor(jwt))
|
||||||
|
@PutMapping("/users/{id}/roles") fun roles(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: RolesCommand) = service.replaceRoles(actor(jwt), id, body.roles)
|
||||||
|
@PutMapping("/users/{id}/assignment") fun assign(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID, @RequestBody body: AssignmentCommand) = service.assign(actor(jwt), id, body.departmentId, body.positionId)
|
||||||
|
}
|
||||||
|
data class DepartmentCommand(@field:NotBlank @field:Size(max=64) val code:String,@field:NotBlank @field:Size(max=200) val name:String)
|
||||||
|
data class RolesCommand(val roles:Set<String>)
|
||||||
|
data class AssignmentCommand(val departmentId:UUID,val positionId:UUID)
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
package com.all8ai.aioa.admin.workflow
|
||||||
|
|
||||||
|
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||||
|
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import org.flowable.engine.HistoryService
|
||||||
|
import org.flowable.engine.RepositoryService
|
||||||
|
import org.flowable.engine.RuntimeService
|
||||||
|
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt
|
||||||
|
import org.springframework.web.bind.annotation.*
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
|
data class ProcessDefinitionView(val id:String,val key:String,val name:String?,val version:Int,val deploymentId:String,val suspended:Boolean)
|
||||||
|
data class ProcessInstanceView(val id:String,val definitionId:String,val businessKey:String?,val startedAt:Instant?,val endedAt:Instant?,val active:Boolean)
|
||||||
|
|
||||||
|
@RestController @RequestMapping("/api/v1/admin/workflows")
|
||||||
|
class AdminWorkflowController(
|
||||||
|
private val currentUsers:CurrentUserService, private val repository:RepositoryService,
|
||||||
|
private val runtime:RuntimeService, private val history:HistoryService,
|
||||||
|
) {
|
||||||
|
private fun actor(jwt:Jwt):CurrentUser=currentUsers.get(jwt.subject,jwt.getClaimAsString("tenant_id")).also { it.requirePermission(ToolPermission.WORKFLOW_READ_TENANT) }
|
||||||
|
@GetMapping("/definitions") fun definitions(@AuthenticationPrincipal jwt:Jwt):List<ProcessDefinitionView> {
|
||||||
|
actor(jwt); return repository.createProcessDefinitionQuery().orderByProcessDefinitionKey().asc().orderByProcessDefinitionVersion().desc().list()
|
||||||
|
.map { ProcessDefinitionView(it.id,it.key,it.name,it.version,it.deploymentId,it.isSuspended) }
|
||||||
|
}
|
||||||
|
@GetMapping("/instances") fun instances(@AuthenticationPrincipal jwt:Jwt,@RequestParam(defaultValue="100") limit:Int):List<ProcessInstanceView> {
|
||||||
|
val actor=actor(jwt); val active=runtime.createProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).listPage(0,limit.coerceIn(1,200))
|
||||||
|
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,null,null,true) }
|
||||||
|
if(active.size>=limit) return active
|
||||||
|
val ended=history.createHistoricProcessInstanceQuery().variableValueEquals("tenantId",actor.tenantId.toString()).finished().orderByProcessInstanceEndTime().desc().listPage(0,(limit-active.size).coerceIn(0,200))
|
||||||
|
.map { ProcessInstanceView(it.id,it.processDefinitionId,it.businessKey,it.startTime?.toInstant(),it.endTime?.toInstant(),false) }
|
||||||
|
return active+ended
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
)
|
||||||
+31
@@ -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)
|
||||||
+32
@@ -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)
|
||||||
+19
@@ -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)
|
||||||
+20
-2
@@ -9,26 +9,32 @@ import com.all8ai.aioa.shared.id.UuidV7
|
|||||||
import com.all8ai.aioa.shared.web.ApiException
|
import com.all8ai.aioa.shared.web.ApiException
|
||||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
|
import com.all8ai.aioa.notification.application.NotificationService
|
||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
import org.springframework.http.HttpStatus
|
import org.springframework.http.HttpStatus
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.transaction.annotation.Transactional
|
import org.springframework.transaction.annotation.Transactional
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class ApprovalTaskService(
|
class ApprovalTaskService(
|
||||||
private val workflowGateway: LeaveWorkflowGateway,
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
private val leaveRequestRepository: LeaveRequestRepository,
|
private val leaveRequestRepository: LeaveRequestRepository,
|
||||||
private val auditService: AuditService,
|
private val auditService: AuditService,
|
||||||
|
private val notificationService: NotificationService? = null,
|
||||||
) {
|
) {
|
||||||
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
|
fun listAssigned(actor: CurrentUser): List<ApprovalTask> {
|
||||||
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
actor.requirePermission(ToolPermission.APPROVAL_TASK_READ_ASSIGNED)
|
||||||
|
return workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
|
||||||
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
|
||||||
?: return@mapNotNull null
|
?: return@mapNotNull null
|
||||||
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
|
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
|
||||||
ApprovalTask(task.id, task.name, task.createdAt, request)
|
ApprovalTask(task.id, task.name, task.createdAt, request)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun approve(
|
fun approve(
|
||||||
@@ -56,6 +62,7 @@ class ApprovalTaskService(
|
|||||||
comment: String?,
|
comment: String?,
|
||||||
approved: Boolean,
|
approved: Boolean,
|
||||||
): LeaveRequest {
|
): LeaveRequest {
|
||||||
|
actor.requirePermission(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
|
||||||
validate(idempotencyKey, expectedVersion, comment)
|
validate(idempotencyKey, expectedVersion, comment)
|
||||||
val task = workflowGateway.resolveTask(taskId)
|
val task = workflowGateway.resolveTask(taskId)
|
||||||
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
|
||||||
@@ -131,6 +138,17 @@ class ApprovalTaskService(
|
|||||||
"comment" to comment?.trim(),
|
"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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+37
-4
@@ -17,11 +17,16 @@ import org.springframework.transaction.annotation.Transactional
|
|||||||
import org.slf4j.MDC
|
import org.slf4j.MDC
|
||||||
import com.all8ai.aioa.shared.web.TraceIdFilter
|
import com.all8ai.aioa.shared.web.TraceIdFilter
|
||||||
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
||||||
|
import com.all8ai.aioa.notification.application.NotificationService
|
||||||
import java.nio.charset.StandardCharsets
|
import java.nio.charset.StandardCharsets
|
||||||
import java.security.MessageDigest
|
import java.security.MessageDigest
|
||||||
import java.time.Instant
|
import java.time.Instant
|
||||||
import java.time.Duration
|
import java.time.Duration
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import com.all8ai.aioa.shared.security.ToolPermission
|
||||||
|
import com.all8ai.aioa.shared.security.requirePermission
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||||
|
import com.all8ai.aioa.workflow.infrastructure.FlowableLeaveWorkflowGateway
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class LeaveRequestService(
|
class LeaveRequestService(
|
||||||
@@ -29,8 +34,11 @@ class LeaveRequestService(
|
|||||||
private val auditService: AuditService,
|
private val auditService: AuditService,
|
||||||
private val routingRepository: ApprovalRoutingRepository,
|
private val routingRepository: ApprovalRoutingRepository,
|
||||||
private val workflowGateway: LeaveWorkflowGateway,
|
private val workflowGateway: LeaveWorkflowGateway,
|
||||||
|
private val notificationService: NotificationService? = null,
|
||||||
|
private val processBindingRouter: ProcessBindingRouter? = null,
|
||||||
) {
|
) {
|
||||||
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
|
||||||
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
validateIdempotencyKey(idempotencyKey)
|
validateIdempotencyKey(idempotencyKey)
|
||||||
val content = command.toValidatedContent()
|
val content = command.toValidatedContent()
|
||||||
val fingerprint = fingerprint(content)
|
val fingerprint = fingerprint(content)
|
||||||
@@ -53,6 +61,7 @@ class LeaveRequestService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest {
|
fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest {
|
||||||
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
val content = command.toValidatedContent()
|
val content = command.toValidatedContent()
|
||||||
repository.updateDraft(actor.tenantId, actor.id, id, command.version, content)?.let { return it }
|
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", "申请已被其他操作更新,请刷新后重试")
|
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest =
|
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest {
|
||||||
repository.findOwn(actor.tenantId, actor.id, id)
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
|
||||||
|
return repository.findOwn(actor.tenantId, actor.id, id)
|
||||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
?: 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
|
@Transactional
|
||||||
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||||
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
val existing = getOwn(actor, id)
|
val existing = getOwn(actor, id)
|
||||||
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
|
||||||
|
val processDefinitionKey = processBindingRouter?.select(
|
||||||
|
actor.tenantId, "LEAVE_REQUEST", existing.type.name, durationMinutes,
|
||||||
|
)?.processDefinitionKey ?: if (processBindingRouter == null) {
|
||||||
|
FlowableLeaveWorkflowGateway.PROCESS_DEFINITION_KEY
|
||||||
|
} else {
|
||||||
|
throw ApiException(HttpStatus.CONFLICT, "PROCESS_BINDING_NOT_FOUND", "未找到适用的已启用审批流程")
|
||||||
|
}
|
||||||
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
|
||||||
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
?: throw ApiException(HttpStatus.CONFLICT, "APPROVER_NOT_FOUND", "未找到当前部门的有效主管")
|
||||||
if (approverId == actor.id) {
|
if (approverId == actor.id) {
|
||||||
@@ -109,7 +131,8 @@ class LeaveRequestService(
|
|||||||
)
|
)
|
||||||
if (outcome.replayed) return outcome.leaveRequest
|
if (outcome.replayed) return outcome.leaveRequest
|
||||||
|
|
||||||
val process = workflowGateway.startLeaveApproval(
|
val process = workflowGateway.startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey,
|
||||||
actor.tenantId,
|
actor.tenantId,
|
||||||
outcome.leaveRequest.id,
|
outcome.leaveRequest.id,
|
||||||
actor.id,
|
actor.id,
|
||||||
@@ -125,6 +148,15 @@ class LeaveRequestService(
|
|||||||
process.processInstanceId,
|
process.processInstanceId,
|
||||||
process.processDefinitionId,
|
process.processDefinitionId,
|
||||||
)
|
)
|
||||||
|
notificationService?.notify(
|
||||||
|
actor.tenantId,
|
||||||
|
approverId,
|
||||||
|
"APPROVAL_TASK_ASSIGNED",
|
||||||
|
"新的请假审批待办",
|
||||||
|
"${actor.displayName} 提交了请假申请,请及时处理。",
|
||||||
|
"LEAVE_REQUEST",
|
||||||
|
outcome.leaveRequest.id.toString(),
|
||||||
|
)
|
||||||
return outcome.leaveRequest.copy(
|
return outcome.leaveRequest.copy(
|
||||||
processInstanceId = process.processInstanceId,
|
processInstanceId = process.processInstanceId,
|
||||||
processDefinitionId = process.processDefinitionId,
|
processDefinitionId = process.processDefinitionId,
|
||||||
@@ -133,6 +165,7 @@ class LeaveRequestService(
|
|||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
|
||||||
|
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
|
||||||
val outcome = transition(
|
val outcome = transition(
|
||||||
actor = actor,
|
actor = actor,
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
+93
@@ -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,
|
||||||
|
)
|
||||||
+113
@@ -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?)
|
||||||
+66
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+51
@@ -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
|
package com.all8ai.aioa.audit.domain
|
||||||
|
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
import java.time.Instant
|
||||||
|
|
||||||
data class AuditEvent(
|
data class AuditEvent(
|
||||||
val id: UUID,
|
val id: UUID,
|
||||||
@@ -18,3 +19,26 @@ data class AuditEvent(
|
|||||||
fun interface AuditEventRepository {
|
fun interface AuditEventRepository {
|
||||||
fun append(event: AuditEvent)
|
fun append(event: AuditEvent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
data class StoredAuditEvent(
|
||||||
|
val id: UUID,
|
||||||
|
val tenantId: UUID,
|
||||||
|
val actorId: UUID,
|
||||||
|
val action: String,
|
||||||
|
val resourceType: String,
|
||||||
|
val resourceId: String?,
|
||||||
|
val traceId: String,
|
||||||
|
val result: String,
|
||||||
|
val occurredAt: Instant,
|
||||||
|
val details: Map<String, Any?>,
|
||||||
|
)
|
||||||
|
|
||||||
|
interface AuditQueryRepository {
|
||||||
|
fun list(
|
||||||
|
tenantId: UUID,
|
||||||
|
traceId: String?,
|
||||||
|
action: String?,
|
||||||
|
resourceType: String?,
|
||||||
|
limit: Int,
|
||||||
|
): List<StoredAuditEvent>
|
||||||
|
}
|
||||||
|
|||||||
+30
-1
@@ -2,15 +2,20 @@ package com.all8ai.aioa.audit.infrastructure
|
|||||||
|
|
||||||
import com.all8ai.aioa.audit.domain.AuditEvent
|
import com.all8ai.aioa.audit.domain.AuditEvent
|
||||||
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
import com.all8ai.aioa.audit.domain.AuditEventRepository
|
||||||
|
import com.all8ai.aioa.audit.domain.AuditQueryRepository
|
||||||
|
import com.all8ai.aioa.audit.domain.StoredAuditEvent
|
||||||
import com.fasterxml.jackson.databind.ObjectMapper
|
import com.fasterxml.jackson.databind.ObjectMapper
|
||||||
import org.jooq.DSLContext
|
import org.jooq.DSLContext
|
||||||
import org.springframework.stereotype.Repository
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.time.OffsetDateTime
|
||||||
|
import java.util.UUID
|
||||||
|
import org.jooq.impl.DSL
|
||||||
|
|
||||||
@Repository
|
@Repository
|
||||||
class JooqAuditEventRepository(
|
class JooqAuditEventRepository(
|
||||||
private val dsl: DSLContext,
|
private val dsl: DSLContext,
|
||||||
private val objectMapper: ObjectMapper,
|
private val objectMapper: ObjectMapper,
|
||||||
) : AuditEventRepository {
|
) : AuditEventRepository, AuditQueryRepository {
|
||||||
override fun append(event: AuditEvent) {
|
override fun append(event: AuditEvent) {
|
||||||
dsl.execute(
|
dsl.execute(
|
||||||
"""
|
"""
|
||||||
@@ -31,4 +36,28 @@ class JooqAuditEventRepository(
|
|||||||
objectMapper.writeValueAsString(event.details),
|
objectMapper.writeValueAsString(event.details),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override fun list(tenantId: UUID, traceId: String?, action: String?, resourceType: String?, limit: Int): List<StoredAuditEvent> {
|
||||||
|
val table = DSL.table(DSL.name("audit", "event"))
|
||||||
|
var condition = DSL.field(DSL.name("tenant_id"), UUID::class.java).eq(tenantId)
|
||||||
|
traceId?.let { condition = condition.and(DSL.field(DSL.name("trace_id"), String::class.java).eq(it)) }
|
||||||
|
action?.let { condition = condition.and(DSL.field(DSL.name("action"), String::class.java).eq(it)) }
|
||||||
|
resourceType?.let { condition = condition.and(DSL.field(DSL.name("resource_type"), String::class.java).eq(it)) }
|
||||||
|
return dsl.select().from(table).where(condition)
|
||||||
|
.orderBy(DSL.field(DSL.name("occurred_at")).desc()).limit(limit).fetch().map { record ->
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
StoredAuditEvent(
|
||||||
|
record.get("id", UUID::class.java)!!,
|
||||||
|
record.get("tenant_id", UUID::class.java)!!,
|
||||||
|
record.get("actor_id", UUID::class.java)!!,
|
||||||
|
record.get("action", String::class.java)!!,
|
||||||
|
record.get("resource_type", String::class.java)!!,
|
||||||
|
record.get("resource_id", String::class.java),
|
||||||
|
record.get("trace_id", String::class.java)!!,
|
||||||
|
record.get("result", String::class.java)!!,
|
||||||
|
record.get("occurred_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||||
|
objectMapper.readValue(record.get("details")!!.toString(), Map::class.java) as Map<String, Any?>,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
+39
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+68
@@ -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.RequestMapping
|
||||||
import org.springframework.web.bind.annotation.RestController
|
import org.springframework.web.bind.annotation.RestController
|
||||||
import java.util.UUID
|
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
|
@RestController
|
||||||
@RequestMapping("/api/v1/me")
|
@RequestMapping("/api/v1/me")
|
||||||
@@ -16,6 +19,7 @@ class CurrentUserController(
|
|||||||
@GetMapping
|
@GetMapping
|
||||||
fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse {
|
fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse {
|
||||||
val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||||
|
val capabilities = AuthorizationPolicy.capabilities(currentUser)
|
||||||
return CurrentUserResponse(
|
return CurrentUserResponse(
|
||||||
id = currentUser.id,
|
id = currentUser.id,
|
||||||
tenantId = currentUser.tenantId,
|
tenantId = currentUser.tenantId,
|
||||||
@@ -25,6 +29,8 @@ class CurrentUserController(
|
|||||||
department = currentUser.department?.let { OrganizationRef(it.id, it.name) },
|
department = currentUser.department?.let { OrganizationRef(it.id, it.name) },
|
||||||
position = currentUser.position?.let { OrganizationRef(it.id, it.name) },
|
position = currentUser.position?.let { OrganizationRef(it.id, it.name) },
|
||||||
roles = currentUser.roles,
|
roles = currentUser.roles,
|
||||||
|
permissions = capabilities.permissions,
|
||||||
|
dataScopes = capabilities.dataScopes,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -38,6 +44,8 @@ data class CurrentUserResponse(
|
|||||||
val department: OrganizationRef?,
|
val department: OrganizationRef?,
|
||||||
val position: OrganizationRef?,
|
val position: OrganizationRef?,
|
||||||
val roles: Set<String>,
|
val roles: Set<String>,
|
||||||
|
val permissions: Set<ToolPermission>,
|
||||||
|
val dataScopes: Set<DataScope>,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class OrganizationRef(
|
data class OrganizationRef(
|
||||||
|
|||||||
+49
@@ -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,
|
||||||
|
)
|
||||||
+59
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
+43
@@ -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
|
||||||
|
}
|
||||||
+41
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -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(),
|
||||||
|
)
|
||||||
|
}
|
||||||
+47
@@ -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.Customizer.withDefaults
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity
|
||||||
import org.springframework.security.web.SecurityFilterChain
|
import org.springframework.security.web.SecurityFilterChain
|
||||||
|
import org.springframework.web.cors.CorsConfiguration
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
class SecurityConfiguration {
|
class SecurityConfiguration {
|
||||||
@Bean
|
@Bean
|
||||||
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
|
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http
|
||||||
.csrf { it.disable() }
|
.csrf { it.disable() }
|
||||||
|
.cors(withDefaults())
|
||||||
.authorizeHttpRequests {
|
.authorizeHttpRequests {
|
||||||
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
it.requestMatchers("/actuator/health", "/actuator/info").permitAll()
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
}
|
}
|
||||||
.oauth2ResourceServer { it.jwt(withDefaults()) }
|
.oauth2ResourceServer { it.jwt(withDefaults()) }
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
fun corsConfigurationSource(): CorsConfigurationSource = UrlBasedCorsConfigurationSource().apply {
|
||||||
|
registerCorsConfiguration("/api/**", CorsConfiguration().apply {
|
||||||
|
allowedOrigins = listOf("http://localhost:5173", "http://127.0.0.1:5173")
|
||||||
|
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
|
||||||
|
allowedHeaders = listOf("Authorization", "Content-Type", "X-AIOA-Device-Id", "Idempotency-Key", "X-Trace-Id")
|
||||||
|
exposedHeaders = listOf("X-Trace-Id", "X-AIOA-Auth-Error")
|
||||||
|
allowCredentials = true
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ interface LeaveWorkflowGateway {
|
|||||||
leaveType: String,
|
leaveType: String,
|
||||||
): StartedProcess
|
): StartedProcess
|
||||||
|
|
||||||
|
fun startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey: String,
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
hrReviewerId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
|
leaveType: String,
|
||||||
|
): StartedProcess = startLeaveApproval(tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
|
||||||
|
|
||||||
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
fun listAssignedTasks(assigneeId: UUID): List<WorkflowTask>
|
||||||
|
|
||||||
fun resolveTask(taskId: String): WorkflowTask?
|
fun resolveTask(taskId: String): WorkflowTask?
|
||||||
@@ -22,6 +34,9 @@ interface LeaveWorkflowGateway {
|
|||||||
fun completeTask(taskId: String, approved: Boolean, comment: String?): TaskCompletion
|
fun completeTask(taskId: String, approved: Boolean, comment: String?): TaskCompletion
|
||||||
|
|
||||||
fun cancelProcess(processInstanceId: String, reason: String)
|
fun cancelProcess(processInstanceId: String, reason: String)
|
||||||
|
|
||||||
|
fun getProgress(processInstanceId: String): WorkflowProgress =
|
||||||
|
error("Workflow progress is not supported")
|
||||||
}
|
}
|
||||||
|
|
||||||
data class StartedProcess(
|
data class StartedProcess(
|
||||||
@@ -43,3 +58,9 @@ data class TaskCompletion(
|
|||||||
val processInstanceId: String,
|
val processInstanceId: String,
|
||||||
val processEnded: Boolean,
|
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?
|
||||||
|
}
|
||||||
+29
-1
@@ -4,6 +4,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
|
|||||||
import com.all8ai.aioa.workflow.domain.StartedProcess
|
import com.all8ai.aioa.workflow.domain.StartedProcess
|
||||||
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
import com.all8ai.aioa.workflow.domain.WorkflowTask
|
||||||
import com.all8ai.aioa.workflow.domain.TaskCompletion
|
import com.all8ai.aioa.workflow.domain.TaskCompletion
|
||||||
|
import com.all8ai.aioa.workflow.domain.WorkflowProgress
|
||||||
import org.flowable.engine.HistoryService
|
import org.flowable.engine.HistoryService
|
||||||
import org.flowable.engine.RuntimeService
|
import org.flowable.engine.RuntimeService
|
||||||
import org.flowable.engine.TaskService
|
import org.flowable.engine.TaskService
|
||||||
@@ -26,9 +27,21 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
hrReviewerId: UUID,
|
hrReviewerId: UUID,
|
||||||
durationMinutes: Long,
|
durationMinutes: Long,
|
||||||
leaveType: String,
|
leaveType: String,
|
||||||
|
): StartedProcess = startLeaveApprovalWithDefinition(PROCESS_DEFINITION_KEY, tenantId, leaveRequestId, applicantId, approverId, oaAdministratorId, hrReviewerId, durationMinutes, leaveType)
|
||||||
|
|
||||||
|
override fun startLeaveApprovalWithDefinition(
|
||||||
|
processDefinitionKey: String,
|
||||||
|
tenantId: UUID,
|
||||||
|
leaveRequestId: UUID,
|
||||||
|
applicantId: UUID,
|
||||||
|
approverId: UUID,
|
||||||
|
oaAdministratorId: UUID,
|
||||||
|
hrReviewerId: UUID,
|
||||||
|
durationMinutes: Long,
|
||||||
|
leaveType: String,
|
||||||
): StartedProcess {
|
): StartedProcess {
|
||||||
val process = runtimeService.createProcessInstanceBuilder()
|
val process = runtimeService.createProcessInstanceBuilder()
|
||||||
.processDefinitionKey(PROCESS_DEFINITION_KEY)
|
.processDefinitionKey(processDefinitionKey)
|
||||||
.businessKey(leaveRequestId.toString())
|
.businessKey(leaveRequestId.toString())
|
||||||
.variables(
|
.variables(
|
||||||
mapOf(
|
mapOf(
|
||||||
@@ -96,6 +109,21 @@ class FlowableLeaveWorkflowGateway(
|
|||||||
runtimeService.deleteProcessInstance(processInstanceId, reason)
|
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(
|
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
|
||||||
id = task.id,
|
id = task.id,
|
||||||
name = task.name,
|
name = task.name,
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
package com.all8ai.aioa.workflow.infrastructure
|
||||||
|
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBinding
|
||||||
|
import com.all8ai.aioa.workflow.domain.ProcessBindingRouter
|
||||||
|
import org.jooq.DSLContext
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
import java.util.UUID
|
||||||
|
|
||||||
|
@Repository
|
||||||
|
class JooqProcessBindingRouter(private val dsl: DSLContext) : ProcessBindingRouter {
|
||||||
|
override fun select(tenantId: UUID, businessType: String, leaveType: String, durationMinutes: Long): ProcessBinding? =
|
||||||
|
dsl.fetchOne(
|
||||||
|
"""
|
||||||
|
SELECT * FROM workflow.process_binding
|
||||||
|
WHERE tenant_id=? AND business_type=? AND status='ACTIVE'
|
||||||
|
AND (leave_type IS NULL OR leave_type=?)
|
||||||
|
AND (min_duration_minutes IS NULL OR min_duration_minutes<=?)
|
||||||
|
AND (max_duration_minutes IS NULL OR max_duration_minutes>=?)
|
||||||
|
ORDER BY priority DESC,
|
||||||
|
(CASE WHEN leave_type IS NULL THEN 0 ELSE 1 END) DESC,
|
||||||
|
(CASE WHEN min_duration_minutes IS NULL AND max_duration_minutes IS NULL THEN 0 ELSE 1 END) DESC
|
||||||
|
LIMIT 1
|
||||||
|
""".trimIndent(), tenantId, businessType, leaveType, durationMinutes, durationMinutes,
|
||||||
|
)?.let { ProcessBinding(
|
||||||
|
it.get("id", UUID::class.java)!!, it.get("business_type", String::class.java)!!,
|
||||||
|
it.get("form_key", String::class.java)!!, it.get("form_version", Int::class.java)!!,
|
||||||
|
it.get("process_definition_key", String::class.java)!!, it.get("leave_type", String::class.java),
|
||||||
|
it.get("min_duration_minutes", Long::class.java), it.get("max_duration_minutes", Long::class.java),
|
||||||
|
it.get("priority", Int::class.java)!!, it.get("status", String::class.java)!!,
|
||||||
|
) }
|
||||||
|
}
|
||||||
@@ -44,3 +44,15 @@ flowable:
|
|||||||
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
|
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
|
||||||
async-executor-activate: false
|
async-executor-activate: false
|
||||||
history-level: audit
|
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';
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
CREATE TABLE form.definition (
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
form_key VARCHAR(100) NOT NULL,
|
||||||
|
version INTEGER NOT NULL,
|
||||||
|
status VARCHAR(32) NOT NULL,
|
||||||
|
data_schema JSONB NOT NULL,
|
||||||
|
ui_schema JSONB NOT NULL,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
published_at TIMESTAMPTZ,
|
||||||
|
PRIMARY KEY (tenant_id, form_key, version),
|
||||||
|
CONSTRAINT ck_form_definition_status CHECK (status IN ('DRAFT', 'PUBLISHED', 'RETIRED'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX uq_form_definition_published
|
||||||
|
ON form.definition (tenant_id, form_key)
|
||||||
|
WHERE status = 'PUBLISHED';
|
||||||
|
|
||||||
|
CREATE TABLE workflow.process_binding (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
|
||||||
|
business_type VARCHAR(100) NOT NULL,
|
||||||
|
form_key VARCHAR(100) NOT NULL,
|
||||||
|
form_version INTEGER NOT NULL,
|
||||||
|
process_definition_key VARCHAR(100) NOT NULL,
|
||||||
|
leave_type VARCHAR(32),
|
||||||
|
min_duration_minutes BIGINT,
|
||||||
|
max_duration_minutes BIGINT,
|
||||||
|
priority INTEGER NOT NULL DEFAULT 100,
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT fk_process_binding_form FOREIGN KEY (tenant_id, form_key, form_version)
|
||||||
|
REFERENCES form.definition(tenant_id, form_key, version),
|
||||||
|
CONSTRAINT ck_process_binding_status CHECK (status IN ('ACTIVE', 'INACTIVE')),
|
||||||
|
CONSTRAINT ck_process_binding_duration CHECK (
|
||||||
|
(min_duration_minutes IS NULL OR min_duration_minutes >= 0) AND
|
||||||
|
(max_duration_minutes IS NULL OR max_duration_minutes >= min_duration_minutes)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX idx_process_binding_route
|
||||||
|
ON workflow.process_binding (tenant_id, business_type, status, priority DESC);
|
||||||
|
|
||||||
|
INSERT INTO form.definition (tenant_id, form_key, version, status, data_schema, ui_schema, published_at)
|
||||||
|
VALUES (
|
||||||
|
'00000000-0000-7000-8000-000000000001', 'leave-request', 1, 'PUBLISHED',
|
||||||
|
'{"$id":"leave-request-v1","title":"请假申请","type":"object","required":["type","startsAt","endsAt","reason"],"properties":{"type":{"type":"string","enum":["PERSONAL","SICK","ANNUAL"]},"startsAt":{"type":"string","format":"date-time"},"endsAt":{"type":"string","format":"date-time"},"reason":{"type":"string","minLength":1,"maxLength":2000}}}'::jsonb,
|
||||||
|
'{"description":"表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。","sections":[{"title":"请假信息","controls":[{"field":"type","label":"请假类型","control":"select","optionLabels":{"PERSONAL":"事假","SICK":"病假","ANNUAL":"年假"}},{"field":"startsAt","label":"开始时间","control":"dateTime"},{"field":"endsAt","label":"结束时间","control":"dateTime"}]},{"title":"补充说明","controls":[{"field":"reason","label":"请假原因","control":"textArea","placeholder":"请简要说明请假原因","helperText":"AI 可以帮助整理表达,但提交前必须由你确认。"}]}]}'::jsonb,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO workflow.process_binding (
|
||||||
|
id, tenant_id, business_type, form_key, form_version, process_definition_key, priority
|
||||||
|
) VALUES (
|
||||||
|
'70000000-0000-7000-8000-000000000001',
|
||||||
|
'00000000-0000-7000-8000-000000000001',
|
||||||
|
'LEAVE_REQUEST', 'leave-request', 1, 'leaveApproval', 100
|
||||||
|
);
|
||||||
@@ -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);
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ApprovalRuleCatalogTest {
|
||||||
|
@Test fun `catalog exposes only supported runtime variables and safe empty policy`() {
|
||||||
|
val catalog=approvalRuleCatalog()
|
||||||
|
assertEquals(setOf("approverId","oaAdministratorId","hrReviewerId"),catalog.map{it.variable}.toSet())
|
||||||
|
assertTrue(catalog.all{it.emptyPolicy=="REJECT_SUBMISSION"})
|
||||||
|
assertTrue(catalog.any{it.sourceType=="POSITION"&&it.scope=="APPLICANT_DEPARTMENT"})
|
||||||
|
assertTrue(catalog.any{it.sourceType=="ROLE"&&it.scope=="TENANT"})
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
class ProcessConfigurationValidationTest {
|
||||||
|
@Test fun `accepts open and ordered duration ranges`() {
|
||||||
|
assertTrue(isValidDurationRange(null,null))
|
||||||
|
assertTrue(isValidDurationRange(0,480))
|
||||||
|
assertTrue(isValidDurationRange(1440,null))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `rejects negative or reversed duration ranges`() {
|
||||||
|
assertFalse(isValidDurationRange(-1,480))
|
||||||
|
assertFalse(isValidDurationRange(960,480))
|
||||||
|
assertFalse(isValidDurationRange(0,-1))
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
@@ -0,0 +1,65 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import org.flowable.engine.ProcessEngine
|
||||||
|
import org.flowable.engine.ProcessEngineConfiguration
|
||||||
|
import org.flowable.engine.impl.cfg.StandaloneInMemProcessEngineConfiguration
|
||||||
|
import org.junit.jupiter.api.AfterEach
|
||||||
|
import org.junit.jupiter.api.BeforeEach
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
|
||||||
|
class ProcessTemplateExecutionTest {
|
||||||
|
private lateinit var engine:ProcessEngine
|
||||||
|
private val manager=ApprovalStepCommand("主管审批","approverId")
|
||||||
|
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
|
||||||
|
private val hr=ApprovalStepCommand("HR 复核","hrReviewerId")
|
||||||
|
private val variables=mapOf(
|
||||||
|
"approverId" to "manager", "oaAdministratorId" to "oa", "hrReviewerId" to "hr",
|
||||||
|
"businessId" to "00000000-0000-7000-8000-000000000001",
|
||||||
|
)
|
||||||
|
|
||||||
|
@BeforeEach fun startEngine() {
|
||||||
|
engine=StandaloneInMemProcessEngineConfiguration()
|
||||||
|
.setJdbcUrl("jdbc:h2:mem:aioa-${System.nanoTime()};DB_CLOSE_DELAY=-1")
|
||||||
|
.setDatabaseSchemaUpdate(ProcessEngineConfiguration.DB_SCHEMA_UPDATE_TRUE)
|
||||||
|
.buildProcessEngine()
|
||||||
|
}
|
||||||
|
@AfterEach fun stopEngine(){engine.close()}
|
||||||
|
|
||||||
|
@Test fun `conditional template skips or creates second approval by duration`() {
|
||||||
|
deploy(ProcessTemplateCommand("conditionalRuntime","条件流程","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
|
||||||
|
val short=start("conditionalRuntime",variables+mapOf("durationMinutes" to 480L))
|
||||||
|
completeOnlyTask(short,"manager",true)
|
||||||
|
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(short).singleResult())
|
||||||
|
|
||||||
|
val long=start("conditionalRuntime",variables+mapOf("durationMinutes" to 1440L))
|
||||||
|
completeOnlyTask(long,"manager",true)
|
||||||
|
assertNotNull(engine.taskService.createTaskQuery().processInstanceId(long).taskAssignee("oa").singleResult())
|
||||||
|
completeOnlyTask(long,"oa",true)
|
||||||
|
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(long).singleResult())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `parallel template waits for every approval and terminates on rejection`() {
|
||||||
|
deploy(ProcessTemplateCommand("parallelRuntime","并行流程","PARALLEL",listOf(oa,hr)))
|
||||||
|
val approved=start("parallelRuntime",variables)
|
||||||
|
assertEquals(2,engine.taskService.createTaskQuery().processInstanceId(approved).count())
|
||||||
|
completeOnlyTask(approved,"oa",true)
|
||||||
|
assertNotNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
|
||||||
|
completeOnlyTask(approved,"hr",true)
|
||||||
|
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(approved).singleResult())
|
||||||
|
|
||||||
|
val rejected=start("parallelRuntime",variables)
|
||||||
|
completeOnlyTask(rejected,"oa",false)
|
||||||
|
assertNull(engine.runtimeService.createProcessInstanceQuery().processInstanceId(rejected).singleResult())
|
||||||
|
assertEquals(0,engine.taskService.createTaskQuery().processInstanceId(rejected).count())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deploy(command:ProcessTemplateCommand){engine.repositoryService.createDeployment().addString("${command.key}.bpmn20.xml",generateProcessTemplate(command)).deploy()}
|
||||||
|
private fun start(key:String,vars:Map<String,Any>)=engine.runtimeService.startProcessInstanceByKey(key,vars).id
|
||||||
|
private fun completeOnlyTask(instanceId:String,assignee:String,approved:Boolean){
|
||||||
|
val task=engine.taskService.createTaskQuery().processInstanceId(instanceId).taskAssignee(assignee).singleResult()
|
||||||
|
assertNotNull(task);engine.taskService.complete(task.id,mapOf("approved" to approved))
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
package com.all8ai.aioa.admin.configuration
|
||||||
|
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContains
|
||||||
|
import kotlin.test.assertFails
|
||||||
|
|
||||||
|
class ProcessTemplateGeneratorTest {
|
||||||
|
private val manager=ApprovalStepCommand("主管审批","approverId")
|
||||||
|
private val oa=ApprovalStepCommand("OA 复核","oaAdministratorId")
|
||||||
|
|
||||||
|
@Test fun `generates serial approval with rejection path`() {
|
||||||
|
val xml=generateProcessTemplate(ProcessTemplateCommand("serialDemo","串行审批","SERIAL",listOf(manager,oa)))
|
||||||
|
assertContains(xml,"sourceRef=\"decision0\" targetRef=\"task1\"")
|
||||||
|
assertContains(xml,"targetRef=\"rejectedEnd\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `generates parallel gateways and condition expressions`() {
|
||||||
|
val parallel=generateProcessTemplate(ProcessTemplateCommand("parallelDemo","并行审批","PARALLEL",listOf(manager,oa)))
|
||||||
|
assertContains(parallel,"parallelGateway id=\"parallelSplit\"")
|
||||||
|
assertContains(parallel,"parallelGateway id=\"parallelJoin\"")
|
||||||
|
val conditional=generateProcessTemplate(ProcessTemplateCommand("conditionalDemo","条件审批","CONDITIONAL",listOf(manager,oa),conditionThreshold=960))
|
||||||
|
assertContains(conditional,"durationMinutes <= 960")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test fun `rejects unsafe identifiers and assignee expressions`() {
|
||||||
|
assertFails { generateProcessTemplate(ProcessTemplateCommand("bad key","测试","SERIAL",listOf(manager))) }
|
||||||
|
assertFails { generateProcessTemplate(ProcessTemplateCommand("safeKey","测试","SERIAL",listOf(ApprovalStepCommand("审批","evilExpression")))) }
|
||||||
|
assertFails { generateProcessTemplate(ProcessTemplateCommand("duplicateParallel","测试","PARALLEL",listOf(manager,manager))) }
|
||||||
|
}
|
||||||
|
}
|
||||||
+49
@@ -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
Reference in New Issue
Block a user