From bbb09ed56f6ae8d47cec7c6a502126488ca1238a Mon Sep 17 00:00:00 2001 From: selfrelease Date: Sat, 18 Jul 2026 08:05:45 +0800 Subject: [PATCH] feat: establish AIOA identity and organization baseline --- .editorconfig | 15 + .env.example | 11 + .gitignore | 30 + .java-version | 1 + AI原生OA技术架构方案.md | 863 ++++++++++++++++++ README.md | 50 + ai-service/README.md | 3 + ...kotlin-compiler-2119854500868872929.salive | 0 backend/README.md | 42 + backend/boot/build.gradle.kts | 40 + .../kotlin/com/all8ai/aioa/AioaApplication.kt | 11 + .../identity/api/CurrentUserController.kt | 46 + .../application/CurrentUserService.kt | 27 + .../aioa/identity/domain/CurrentUser.kt | 23 + .../JooqCurrentUserRepository.kt | 77 ++ .../shared/security/SecurityConfiguration.kt | 20 + .../all8ai/aioa/shared/web/ApiException.kt | 9 + .../aioa/shared/web/GlobalExceptionHandler.kt | 48 + .../all8ai/aioa/shared/web/TraceIdFilter.kt | 39 + .../boot/src/main/resources/application.yaml | 40 + .../db/migration/V1__create_base_schemas.sql | 30 + .../V2__create_identity_and_organization.sql | 103 +++ .../V3__seed_development_identity.sql | 37 + .../application/CurrentUserServiceTest.kt | 55 ++ .../aioa/shared/web/TraceIdFilterTest.kt | 36 + backend/build.gradle.kts | 11 + backend/gradle.properties | 4 + backend/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + backend/gradlew | 248 +++++ backend/gradlew.bat | 82 ++ backend/settings.gradle.kts | 16 + contracts/openapi/aioa-v1.yaml | 138 +++ deploy/compose/compose.yaml | 57 ++ deploy/compose/keycloak/realm-aioa.json | 84 ++ docs/engineering/decisions.md | 21 + docs/engineering/roadmap.md | 37 + docs/product/mvp.md | 52 ++ docs/product/permission-matrix.md | 17 + mobile/README.md | 5 + scripts/README.md | 5 + scripts/verify-local-auth.sh | 31 + 42 files changed, 2473 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .java-version create mode 100644 AI原生OA技术架构方案.md create mode 100644 README.md create mode 100644 ai-service/README.md create mode 100644 backend/.kotlin/sessions/kotlin-compiler-2119854500868872929.salive create mode 100644 backend/README.md create mode 100644 backend/boot/build.gradle.kts create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/identity/application/CurrentUserService.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/identity/domain/CurrentUser.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/identity/infrastructure/JooqCurrentUserRepository.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/SecurityConfiguration.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/ApiException.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt create mode 100644 backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/TraceIdFilter.kt create mode 100644 backend/boot/src/main/resources/application.yaml create mode 100644 backend/boot/src/main/resources/db/migration/V1__create_base_schemas.sql create mode 100644 backend/boot/src/main/resources/db/migration/V2__create_identity_and_organization.sql create mode 100644 backend/boot/src/main/resources/db/migration/V3__seed_development_identity.sql create mode 100644 backend/boot/src/test/kotlin/com/all8ai/aioa/identity/application/CurrentUserServiceTest.kt create mode 100644 backend/boot/src/test/kotlin/com/all8ai/aioa/shared/web/TraceIdFilterTest.kt create mode 100644 backend/build.gradle.kts create mode 100644 backend/gradle.properties create mode 100644 backend/gradle/wrapper/gradle-wrapper.jar create mode 100644 backend/gradle/wrapper/gradle-wrapper.properties create mode 100755 backend/gradlew create mode 100644 backend/gradlew.bat create mode 100644 backend/settings.gradle.kts create mode 100644 contracts/openapi/aioa-v1.yaml create mode 100644 deploy/compose/compose.yaml create mode 100644 deploy/compose/keycloak/realm-aioa.json create mode 100644 docs/engineering/decisions.md create mode 100644 docs/engineering/roadmap.md create mode 100644 docs/product/mvp.md create mode 100644 docs/product/permission-matrix.md create mode 100644 mobile/README.md create mode 100644 scripts/README.md create mode 100755 scripts/verify-local-auth.sh diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..5c8c93a --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..723a94b --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +POSTGRES_DB=aioa +POSTGRES_USER=aioa +POSTGRES_PASSWORD=change-me +DB_URL=jdbc:postgresql://127.0.0.1:15432/aioa +DB_USER=aioa +DB_PASSWORD=change-me +OIDC_ISSUER_URI=http://localhost:8081/realms/aioa +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=change-me +MINIO_ROOT_USER=minioadmin +MINIO_ROOT_PASSWORD=change-me-now diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6a42f2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,30 @@ +.DS_Store +.env +.idea/ +.vscode/ +*.iml + +# Kotlin / Gradle +.gradle/ +**/build/ + +# Flutter / Dart +.dart_tool/ +.flutter-plugins +.flutter-plugins-dependencies +.packages +**/.pub-cache/ +**/ios/Pods/ + +# Python +__pycache__/ +*.py[cod] +.venv/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Local data +.local/ +coverage/ +reports/ diff --git a/.java-version b/.java-version new file mode 100644 index 0000000..aabe6ec --- /dev/null +++ b/.java-version @@ -0,0 +1 @@ +21 diff --git a/AI原生OA技术架构方案.md b/AI原生OA技术架构方案.md new file mode 100644 index 0000000..2e37d66 --- /dev/null +++ b/AI原生OA技术架构方案.md @@ -0,0 +1,863 @@ +# AI 原生 OA 技术架构方案 + +> 文档状态:技术栈定稿 +> 系统形态:完全移动端 +> 目标平台:iOS、Android +> 核心后端:Kotlin + Spring Boot + Flowable +> 主数据库:PostgreSQL + +## 1. 项目目标 + +本项目建设一套全新的 AI 原生移动办公系统,不复制传统 OA 的“菜单—表单—审批”使用方式,而是以移动工作台、AI 助手和任务中心为主要入口。 + +系统需要同时满足以下目标: + +- 完全移动端运行,统一支持 iOS 和 Android。 +- 覆盖组织、人员、权限、动态表单、审批、知识、通知和文件等 OA 基础能力。 +- 支持员工通过自然语言查询信息、生成材料、填写表单和发起业务流程。 +- 支持 AI 在明确授权范围内调用业务能力,但不得绕过权限、审批和审计。 +- 支持企业私有化部署,并保留接入不同大模型的能力。 +- 支持从数百用户扩展至数万用户。 +- 业务数据、流程数据和 AI 操作全过程可追溯、可审计、可恢复。 + +## 2. 总体设计原则 + +### 2.1 确定性业务与 AI 分离 + +权限判断、审批结果、金额计算、状态变更和事务一致性由 Kotlin 核心后端负责。AI 负责意图理解、知识检索、任务规划、信息提取和内容生成。 + +大模型不得直接访问生产数据库,不得直接修改审批状态,也不得自行获得高于当前用户的权限。 + +### 2.2 模块化单体优先 + +第一阶段采用模块化单体,而不是全面微服务。核心业务部署为一个应用,但在代码、数据库访问和领域接口层面保持清晰的模块边界。 + +以下服务可以独立部署: + +- AI 服务 +- 文档处理服务 +- 消息通知服务 +- 搜索与索引任务 +- 外部系统集成任务 + +只有当某个模块确实需要独立扩容、独立发布、故障隔离或由不同团队负责时,才将其拆为微服务。 + +### 2.3 移动优先 + +所有功能首先按照手机操作场景设计: + +- 减少复杂菜单和多层导航。 +- 表单适合单手操作和分步填写。 +- 支持拍照、扫码、语音、定位、文件上传和生物识别。 +- 弱网状态下提供本地草稿、失败重试和状态恢复。 +- 重要写操作提供明确的确认界面。 + +### 2.4 默认安全 + +权限、数据范围、日志、审计、加密和敏感操作确认必须从第一版进入系统设计,不能作为后期补充功能。 + +### 2.5 技术可替换 + +大模型、向量模型、对象存储、搜索引擎和消息基础设施通过内部接口隔离,避免核心业务绑定某个云厂商或模型供应商。 + +## 3. 确定技术栈 + +| 层级 | 确定选型 | +|---|---| +| 移动端框架 | Flutter + Dart | +| 状态管理 | Riverpod | +| 路由 | GoRouter | +| HTTP 客户端 | Dio | +| 数据模型 | Freezed + json_serializable | +| 本地数据库 | Drift + SQLite | +| 安全存储 | flutter_secure_storage | +| 移动端监控 | Sentry | +| 核心后端 | Kotlin + Spring Boot | +| Java 运行时 | JDK 21 LTS | +| 构建系统 | Gradle Kotlin DSL | +| 工作流 | Flowable BPMN + DMN | +| 数据访问 | jOOQ + HikariCP | +| 数据库迁移 | Flyway | +| 主数据库 | PostgreSQL | +| 向量检索 | pgvector | +| 数据库连接代理 | PgBouncer | +| 缓存和分布式锁 | Redis | +| 消息队列 | Kafka | +| 企业全文搜索 | OpenSearch | +| 文件与对象存储 | MinIO(S3 兼容) | +| AI 服务 | Python + FastAPI + LangGraph | +| 身份认证 | Keycloak + OAuth 2.1/OIDC | +| API 网关 | Apache APISIX | +| 实时业务消息 | WebSocket | +| AI 流式响应 | SSE | +| 接口规范 | REST + OpenAPI 3 | +| 可观测性 | OpenTelemetry + Prometheus + Grafana + Loki + Tempo | +| 密钥管理 | HashiCorp Vault | +| 容器与编排 | Docker + Kubernetes | +| 发布管理 | Helm | +| CI/CD | GitLab CI | +| 后端测试 | Kotest + MockK + Testcontainers | +| 代码质量 | Detekt + ktlint | +| 移动端测试 | flutter_test + integration_test | +| 性能测试 | k6 | + +项目创建时选择各组件的稳定版本,并使用依赖锁文件、容器镜像版本及镜像摘要固定版本。生产环境禁止使用浮动的 `latest` 标签。 + +## 4. 总体架构 + +```text + Flutter iOS / Android + │ + HTTPS + │ + Apache APISIX + │ + Keycloak 身份认证 + │ + ┌──────────────┴──────────────┐ + │ │ + Kotlin + Spring Boot Python AI 服务 + 核心业务与权限 检索、规划、生成 + │ │ + Flowable 模型网关 + │ │ + ├──────────────┬──────────────┤ + │ │ │ + PostgreSQL Kafka Redis + │ │ │ + pgvector 异步事件 缓存与锁 + │ + ┌─────┴──────────┐ + │ │ +MinIO OpenSearch +文件存储 全文检索 +``` + +主要调用路径: + +```text +用户操作 + → Flutter 客户端 + → APISIX 网关 + → Kotlin 核心后端 + → 身份、权限和数据范围校验 + → 业务服务或 Flowable + → PostgreSQL 事务提交 + → Kafka 发布领域事件 + → 通知、索引、AI 或外部集成异步处理 +``` + +AI 操作路径: + +```text +用户自然语言请求 + → Kotlin 后端验证身份并整理上下文 + → AI 服务理解意图并生成执行计划 + → Kotlin 后端验证工具和参数权限 + → Flutter 展示确认卡片 + → 用户确认 + → Kotlin 后端执行实际业务操作 + → 写入业务数据和完整审计记录 +``` + +## 5. 移动端技术方案 + +### 5.1 选择 Flutter + +移动端确定使用 Flutter,不采用 React Native,也不分别开发 Kotlin Android 和 Swift iOS 客户端。 + +选择 Flutter 的主要理由: + +- 一套代码同时覆盖 iOS 和 Android。 +- 复杂表单、审批时间线、流程图和工作台在双端保持一致。 +- 渲染行为可控,便于形成统一企业设计系统。 +- 具备拍照、扫码、定位、推送、生物识别和安全存储等成熟能力。 +- 相比维护两套原生客户端,可降低长期研发和测试成本。 +- Dart 强类型体系适合大型、长期维护的企业应用。 + +### 5.2 客户端架构 + +采用 Feature-first + Clean Architecture: + +```text +mobile/ +├── app/ # 初始化、环境、主题、路由 +├── core/ # 网络、安全、存储、错误模型 +├── design_system/ # 颜色、字号、间距和通用组件 +└── features/ + ├── auth/ # 登录、设备和身份 + ├── workspace/ # 工作台 + ├── assistant/ # AI 助手 + ├── approval/ # 待办与审批 + ├── form/ # 动态表单 + ├── workflow/ # 流程详情 + ├── knowledge/ # 企业知识 + ├── notification/ # 消息中心 + ├── contact/ # 组织与通讯录 + └── profile/ # 用户设置 +``` + +每个业务功能内部保持以下结构: + +```text +feature/ +├── data/ # API、DTO、本地数据 +├── domain/ # 实体、仓库接口、用例 +└── presentation/ # 页面、组件、状态控制器 +``` + +### 5.3 一级导航 + +移动端固定设置四个一级入口: + +```text +工作台 | AI 助手 | 待办 | 我的 +``` + +- 工作台:常用服务、日程、公告、数据卡片和快捷操作。 +- AI 助手:对话、任务发起、知识查询和执行确认。 +- 待办:审批、抄送、任务、提醒及状态跟踪。 +- 我的:个人信息、权限、设备、安全和偏好设置。 + +### 5.4 离线与弱网 + +Drift + SQLite 保存: + +- 未提交表单草稿 +- 最近访问的非敏感数据 +- 待上传附件状态 +- 客户端操作队列 +- 消息与页面缓存 + +所有写操作必须携带幂等键。客户端断网恢复后可以安全重试,但不能因此重复发起流程或重复审批。 + +高敏感数据不进入普通缓存;必要的本地数据采用操作系统安全能力保护。 + +### 5.5 推送与安全存储 + +- iOS 使用 APNs。 +- Android 使用 FCM;如需适配特殊终端,可在通知服务中增加厂商通道。 +- 服务端维护统一通知抽象,不让业务模块直接调用具体推送平台。 +- Access Token、Refresh Token 和设备密钥存入 iOS Keychain 或 Android Keystore。 +- 高风险操作支持系统生物识别二次确认。 + +## 6. Kotlin 核心后端 + +### 6.1 架构形式 + +核心后端采用: + +```text +模块化单体 ++ 领域驱动的模块划分 ++ 六边形架构 ++ 领域事件 ++ 异步任务 +``` + +初始模块: + +```text +backend/ +├── boot # 应用启动与配置 +├── identity # 用户身份和账号 +├── organization # 组织、岗位和人员关系 +├── authorization # 角色、权限和数据范围 +├── workflow # Flowable 适配和流程管理 +├── form # 表单模型和版本 +├── approval # 审批业务 +├── document # 文件元数据与文档业务 +├── knowledge # 知识库和权限 +├── notification # 站内消息和通知 +├── integration # 外部系统适配 +├── ai-orchestration # AI 上下文和工具执行 +└── audit # 审计事件和查询 +``` + +模块之间通过应用服务接口和领域事件协作。一个模块不得直接读取或修改另一个模块拥有的数据库表。 + +### 6.2 Spring 编程模型 + +业务接口采用 Spring MVC,不以 WebFlux 作为核心编程模型。Flowable、jOOQ 和企业集成大量使用阻塞式接口,采用 MVC 能降低事务、调试和维护复杂度。 + +AI 流式输出使用 SSE;实时消息和状态更新使用 WebSocket,不需要为此将整个系统改为响应式架构。 + +### 6.3 数据访问 + +确定使用 jOOQ,不采用 JPA/Hibernate。 + +原因包括: + +- 与 Kotlin 的空安全和不可变数据模型配合更自然。 +- 支持类型安全 SQL。 +- 复杂筛选、统计、报表和关联查询更透明。 +- 能充分利用 PostgreSQL 的 JSONB、数组、CTE 和窗口函数。 +- SQL 性能更容易分析和优化。 + +数据库连接由 HikariCP 管理,数据库外部增加 PgBouncer 控制连接总量。 + +## 7. Flowable 工作流方案 + +### 7.1 Flowable 职责 + +Flowable 负责: + +- BPMN 流程执行 +- 用户任务 +- 会签和或签 +- 条件分支 +- 子流程 +- 定时任务 +- 超时提醒与升级 +- 流程撤回和终止控制 +- 流程版本管理 +- DMN 决策表 + +### 7.2 业务数据边界 + +完整表单和业务数据存放在自有业务表中。Flowable 只保存流程运行所需的少量变量,例如: + +```text +businessId +applicantId +departmentId +amount +riskLevel +formVersion +``` + +不得将完整表单 JSON、附件内容或大量业务字段长期放入流程变量,以免造成运行时表膨胀、查询困难和升级风险。 + +### 7.3 流程发布 + +流程生命周期固定为: + +```text +草稿 + → BPMN/DMN 静态校验 + → 测试环境运行 + → 业务负责人审核 + → 正式发布 + → 版本冻结 +``` + +运行中的流程固定引用已发布版本。新版本不能直接改变正在执行的旧流程实例。 + +AI 可以根据自然语言生成 BPMN 或决策表草案,但不能自动发布生产流程。 + +## 8. PostgreSQL 数据方案 + +### 8.1 扩展组件 + +| 扩展 | 用途 | +|---|---| +| pgvector | 文档和知识向量检索 | +| pg_trgm | 模糊查询和相似文本搜索 | +| unaccent | 搜索文本规范化 | +| uuid-ossp | UUID 支持 | +| pg_stat_statements | SQL 性能分析 | + +### 8.2 Schema 划分 + +```text +identity +organization +authz +workflow +form +business +knowledge +integration +audit +flowable +``` + +Flowable 使用独立的 `flowable` Schema。业务模块根据归属访问对应 Schema,但仍由同一个 PostgreSQL 集群统一管理事务和备份。 + +### 8.3 数据建模规范 + +- 主键统一采用 UUIDv7。 +- 所有业务表包含 `tenant_id`。 +- 所有可修改实体包含创建时间、更新时间和乐观锁版本号。 +- 所有写请求包含业务幂等键。 +- 使用 `timestamptz` 保存时间,并统一以 UTC 入库。 +- 金额采用 `numeric`,禁止使用浮点类型。 +- 高频查询字段采用结构化列。 +- 低频扩展字段可以存入 JSONB。 +- 不采用“所有动态表单数据都存 JSONB”的设计。 +- 审计数据与普通业务日志分离。 +- 禁止在生产环境使用 ORM 或 Flowable 自动建表。 + +### 8.4 动态表单 + +动态表单采用: + +- JSON Schema:字段、类型和数据约束。 +- UI Schema:布局、控件和移动端展示规则。 +- 受限规则表达式:显隐、只读、校验和字段联动。 +- BPMN:表单提交后的业务流转。 + +规则表达式采用 CEL 或 FEEL 等受限语言,不允许直接执行用户提供的 JavaScript。 + +表单定义必须版本化。已提交记录必须保留提交时的表单版本和数据快照。 + +### 8.5 高可用与备份 + +- PostgreSQL 采用主从或托管高可用部署。 +- 配置持续归档和时间点恢复能力。 +- 定期进行全量备份和恢复演练。 +- 备份文件加密并与生产集群隔离。 +- 使用 PgBouncer 控制连接规模。 +- 使用 `pg_stat_statements` 和慢查询监控持续优化 SQL。 + +## 9. AI 服务 + +### 9.1 服务边界 + +AI 服务独立使用 Python + FastAPI + LangGraph: + +```text +ai-service/ +├── model_gateway/ # 模型统一接口、路由和故障切换 +├── retrieval/ # 检索、重排序和引用 +├── agents/ # 有状态任务编排 +├── tools/ # 工具定义,不直接实现核心业务 +├── guardrails/ # 输入输出和安全规则 +├── prompts/ # 提示词及版本 +├── evaluation/ # 离线评测和回归测试 +└── telemetry/ # 调用、成本、延迟和质量监控 +``` + +职责划分: + +- Kotlin:身份、权限、业务规则、事务和实际工具执行。 +- Flowable:确定性流程执行和状态推进。 +- Python:意图识别、任务规划、知识检索和内容生成。 +- 大模型:生成建议和结构化调用请求,不直接拥有业务权限。 + +### 9.2 模型网关 + +模型网关提供统一内部协议,并兼容主流 OpenAI API 风格接口。网关负责: + +- 云端模型和私有模型切换 +- 按任务选择模型 +- 超时、重试和熔断 +- 速率和成本控制 +- 敏感信息处理 +- 提示词版本记录 +- 模型调用审计 +- 输出结构校验 + +核心业务代码不得直接调用具体模型供应商 SDK。 + +### 9.3 Agent 运行规则 + +- Agent 任务状态持久化,不能仅存在内存。 +- 每个工具都有确定的输入 Schema、权限要求和风险等级。 +- 工具调用由 Kotlin 后端重新鉴权。 +- 任何写操作都必须具备幂等机制。 +- 中高风险操作必须人工确认。 +- 长任务支持超时、取消、重试和人工接管。 +- 工具执行失败后不能由模型无限重试。 +- 所有关键步骤保存模型、提示词、参数、结果和确认记录。 + +## 10. 企业知识与搜索 + +采用 PostgreSQL + pgvector + OpenSearch 的混合检索架构: + +- PostgreSQL:文档元数据、权限、版本、分片和向量。 +- MinIO:原始文件、附件和预览文件。 +- OpenSearch:全文检索、关键词匹配、过滤和聚合。 +- pgvector:语义召回。 +- AI 服务:混合召回、重排序、引用组织和答案生成。 + +每个文档分片至少携带: + +```text +tenant_id +document_id +document_version +department_id +security_level +permission_tags +effective_time +``` + +知识查询必须先按照用户身份和数据范围生成过滤条件,再执行全文或向量召回。禁止先检索企业全部文档,再依赖大模型隐藏无权查看的内容。 + +AI 回答必须提供来源文档和版本引用。无法找到可靠依据时,应明确说明,而不是生成看似确定的企业制度答案。 + +## 11. 身份与权限 + +### 11.1 认证 + +Keycloak 负责身份协议、登录会话和统一认证,采用: + +```text +OAuth 2.1 ++ OpenID Connect ++ Authorization Code ++ PKCE +``` + +Keycloak 可以对接企业 LDAP、Active Directory、企业微信、钉钉或其他身份提供方。 + +### 11.2 授权 + +业务后端采用: + +```text +RBAC + 数据范围 + ABAC +``` + +- RBAC:角色可以使用哪些功能和工具。 +- 数据范围:本人、本部门、本部门及下级、项目或全公司。 +- ABAC:根据金额、密级、时间、岗位、设备和业务属性判断。 + +必须支持: + +- 多岗位和多角色 +- 临时授权 +- 代理审批 +- 权限生效与失效时间 +- 项目成员权限 +- 组织调整后的权限重算 +- AI 工具级权限 +- 敏感字段级权限 + +Keycloak 不负责所有业务授权。具体业务资源和数据范围仍由 Kotlin 后端判断。 + +## 12. 消息与实时能力 + +### 12.1 Kafka + +Kafka 承担: + +- 流程状态事件 +- 审批结果事件 +- 通知事件 +- 文档解析和向量化任务 +- AI 长任务 +- 搜索索引更新 +- 外部系统同步 +- 审计事件投递 + +事件采用明确的版本号和 Schema。消费者必须实现幂等处理和死信机制。 + +### 12.2 Redis + +Redis 用于: + +- 短期缓存 +- API 限流 +- 幂等令牌 +- 分布式锁 +- 在线状态 +- 短期会话状态 +- 短生命周期任务进度 + +Redis 不作为权威业务数据源。重要状态必须持久化到 PostgreSQL。 + +### 12.3 通知 + +```text +业务事件 + → Kafka + → 通知中心 + → 站内消息 + → APNs / FCM + → Flutter 客户端 +``` + +推送只用于提醒。完整通知内容及已读状态保存在通知中心,确保推送丢失后仍可查询。 + +## 13. 文件与对象存储 + +文件内容统一存储到 MinIO,PostgreSQL 只保存元数据、业务关系、哈希、状态和审计信息。 + +上传过程: + +```text +Flutter 请求上传任务 + → Kotlin 鉴权并生成临时凭证 + → Flutter 直接分片上传 MinIO + → Flutter 通知上传完成 + → Kotlin 校验并创建文件记录 + → Kafka 启动扫描、解析和预览任务 +``` + +文件服务负责: + +- 文件类型和大小校验 +- 哈希校验和去重 +- 分片上传与断点续传 +- 病毒扫描 +- 敏感内容检测 +- 图片压缩和缩略图 +- PDF 预览 +- Office 文档转换 +- 水印 +- 下载权限审计 +- 生命周期和归档策略 + +下载使用短期签名 URL。敏感文件每次生成下载地址前必须重新鉴权。 + +## 14. 多租户 + +即使首期只服务一个组织,核心业务表也保留 `tenant_id`。 + +初期采用: + +```text +共享 PostgreSQL 集群 ++ 共享 Schema ++ tenant_id 行级隔离 +``` + +高敏感表可额外启用 PostgreSQL Row-Level Security,但数据库策略不能替代应用层鉴权。 + +初期不采用每租户独立数据库。未来对隔离要求极高的客户,可以通过租户路由扩展至独立数据库或独立集群。 + +## 15. API 设计 + +- 面向客户端采用 REST API。 +- 接口使用 OpenAPI 3 描述并生成客户端类型。 +- URL 包含显式 API 大版本,如 `/api/v1`。 +- 使用统一错误结构和业务错误码。 +- 分页统一采用游标或明确的分页对象。 +- 写操作支持 `Idempotency-Key`。 +- 资源更新采用版本号或 ETag 防止覆盖。 +- 批量接口设置最大数量和速率限制。 +- AI 流式回答使用 SSE。 +- 实时待办和消息更新使用 WebSocket。 + +客户端不得直接调用 Flowable、Keycloak 管理端、MinIO 管理端或 AI 模型供应商接口。 + +## 16. 安全基线 + +系统第一版必须包含: + +- 全链路 TLS。 +- Access Token 短有效期。 +- Refresh Token 轮换和复用检测。 +- 设备注册、设备撤销和远程注销。 +- API 限流和异常行为监控。 +- 写接口防重放和幂等控制。 +- 数据传输加密与静态加密。 +- 高敏感字段应用层加密。 +- 日志敏感字段脱敏。 +- 完整审批和工具调用审计。 +- 高风险操作二次确认或生物识别。 +- 外部文档不可信标记。 +- 提示词注入检测和工具参数约束。 +- AI 输出结构验证。 +- AI 不能直接将自然语言输出作为数据库语句或业务指令执行。 +- Secret 统一保存在 Vault,不写入代码仓库或容器镜像。 + +## 17. 审计体系 + +每个关键操作至少记录: + +- 租户和用户 +- 登录身份与代理身份 +- 设备和会话 +- 操作时间和来源 IP +- 业务对象与操作类型 +- 操作前后关键状态 +- 权限判断结果 +- 幂等键和关联追踪 ID +- Flowable 流程实例和任务 ID +- AI 模型、提示词版本和工具调用 +- 人工确认记录 +- 执行结果和失败原因 + +审计事件写入独立审计表,并通过 Kafka 异步归档。普通管理员不得修改或删除审计记录。 + +## 18. 可观测性 + +OpenTelemetry 统一采集 Trace、Metric 和 Log 关联信息: + +- Prometheus:指标存储。 +- Grafana:仪表盘和告警。 +- Loki:日志聚合。 +- Tempo:分布式追踪。 +- Sentry:Flutter 崩溃和前端异常。 + +重点监控: + +- API 延迟、错误率和吞吐量 +- PostgreSQL 连接、锁和慢查询 +- Flowable 待执行任务、失败任务和定时任务积压 +- Kafka 消费延迟和死信数量 +- Redis 命中率和内存 +- 文档解析及索引积压 +- AI 请求延迟、Token 消耗、成本、失败率和人工拒绝率 +- 移动端启动速度、崩溃率和网络失败率 + +所有请求使用统一 Trace ID,贯穿 Flutter、APISIX、Kotlin、AI 服务、Kafka 消费者和外部系统调用。 + +## 19. 部署方案 + +### 19.1 环境 + +```text +development +testing +staging +production +``` + +生产数据库、Kafka、对象存储、搜索引擎和密钥系统不得与非生产环境共享。 + +### 19.2 Kubernetes 部署单元 + +- APISIX 网关 +- Kotlin OA Backend +- AI Service +- Document Worker +- Notification Worker +- Integration Worker +- Keycloak +- PostgreSQL 或外部高可用数据库 +- PgBouncer +- Redis +- Kafka +- MinIO +- OpenSearch +- 可观测性组件 + +核心后端初期作为一个部署单元运行多个副本。AI、文档、通知和集成任务根据实际负载独立扩容。 + +### 19.3 发布 + +- GitLab CI 执行代码检查、测试、构建和镜像扫描。 +- Helm 管理 Kubernetes 发布配置。 +- 数据库变更使用 Flyway,并在应用发布前单独执行。 +- 生产发布采用滚动或金丝雀策略。 +- 高风险变更必须提供回滚方案。 +- 数据库迁移遵循向前兼容,避免新版本发布期间旧实例无法运行。 + +## 20. 测试策略 + +### 20.1 后端 + +- Kotest:单元测试和业务规则测试。 +- MockK:外部依赖模拟。 +- Testcontainers:PostgreSQL、Kafka、Redis 和其他集成测试。 +- Flowable 测试:流程路径、条件分支、会签、超时和撤回。 +- 契约测试:移动端、AI 服务和外部系统接口。 +- k6:API 和关键流程压力测试。 + +权限和流程规则优先使用真实数据库和真实 Flowable 引擎做集成测试,避免仅依赖 Mock 得到错误信心。 + +### 20.2 Flutter + +- 领域和状态管理单元测试。 +- Widget 组件测试。 +- Golden UI 回归测试。 +- iOS 和 Android 集成测试。 +- 弱网、断网、后台恢复和 Token 过期测试。 +- 上传中断和幂等重试测试。 + +### 20.3 AI + +- 固定评测集。 +- 意图识别准确率。 +- 知识引用正确率。 +- 无权限信息泄漏测试。 +- 工具选择与参数正确率。 +- 提示词注入测试。 +- 不同模型版本回归测试。 +- 高风险操作人工确认覆盖率。 + +## 21. 第一阶段不引入的技术 + +为保证可交付性,第一阶段明确不引入: + +- 全面微服务 +- 服务网格 +- GraphQL +- 自研 Kubernetes Operator +- 独立向量数据库 +- 多 Agent 自主协作网络 +- Event Sourcing +- 自研工作流引擎 +- 自研身份认证系统 +- 用户脚本或动态 JavaScript 表单规则 +- 大模型直接访问业务数据库 +- 大模型自主发布流程 + +## 22. 建议实施阶段 + +### 阶段一:基础平台 + +- Flutter 应用骨架和设计系统 +- Keycloak 登录、设备注册和组织同步 +- 组织、人员、岗位、角色和数据权限 +- PostgreSQL、Flyway、jOOQ 和审计基础设施 +- APISIX、Kubernetes、监控和日志 + +### 阶段二:OA 核心 + +- 动态表单 +- Flowable 流程设计、发布和执行 +- 待办、已办、抄送和流程详情 +- 文件上传、预览和通知中心 +- 移动端离线草稿和弱网恢复 + +### 阶段三:AI 助手 + +- 模型网关 +- 企业知识库和混合检索 +- 带引用的制度问答 +- 表单自动填写和材料检查 +- AI 操作确认卡片 +- 工具权限和 AI 审计 + +### 阶段四:自动化与集成 + +- ERP、CRM、财务、人力和邮件集成 +- 跨系统任务编排 +- 低风险任务自动执行 +- 异常监控、任务补偿和人工接管 +- AI 质量、成本和业务收益评估 + +## 23. 最终技术基线 + +```text +客户端: +Flutter + Riverpod + GoRouter + Dio + Drift + +核心业务: +Kotlin + Spring Boot + Flowable + jOOQ + +AI: +Python + FastAPI + LangGraph + +数据: +PostgreSQL + pgvector + PgBouncer + Flyway +Redis + OpenSearch + +基础设施: +Kafka + MinIO + Keycloak + APISIX + Vault + +部署: +Docker + Kubernetes + Helm + GitLab CI + +运维: +OpenTelemetry + Prometheus + Grafana + Loki + Tempo + Sentry +``` + +技术架构的核心边界如下: + +- Flutter 负责统一移动体验。 +- Kotlin 负责确定性业务、权限和事务。 +- Flowable 负责可审计的流程执行。 +- Python AI 服务负责理解、检索、规划和生成。 +- PostgreSQL 保存权威业务数据。 +- Kafka 承担异步事件和系统解耦。 +- MinIO 保存文件,OpenSearch 与 pgvector 提供混合检索。 +- Keycloak 负责身份协议,Kotlin 后端负责业务授权。 +- AI 永远不能绕过权限、确认和审计直接执行高风险操作。 + +该技术基线兼顾移动体验、企业级可靠性、AI 扩展能力、私有化部署和长期维护成本,可作为项目立项、原型开发和架构评审的统一依据。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e29dfbd --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# AIOA + +AI 原生移动办公系统。项目采用“纵向业务闭环优先”的实施方式,首个里程碑是请假审批闭环。 + +## 当前范围 + +首期只实现: + +- OIDC 登录与当前用户 +- 基础组织、岗位和角色 +- 请假申请、审批、驳回、撤回和流程时间线 +- 附件、站内通知和审计 +- AI 辅助填写请假单、查询流程进度 + +不在首期实现:全面微服务、多 Agent、自主审批、复杂知识库、生产级 Kubernetes 集群。 + +## 仓库结构 + +```text +backend/ Kotlin + Spring Boot 模块化单体 +mobile/ Flutter 移动客户端 +ai-service/ Python AI 服务 +contracts/ OpenAPI 与事件契约 +deploy/ 本地与部署配置 +docs/ 产品、架构和开发文档 +scripts/ 开发辅助脚本 +``` + +## 开发环境 + +- JDK 21 +- Docker 28+ +- Docker Compose v2 +- Flutter stable(与当前主机架构一致) +- Python 3.11+ + +复制 `.env.example` 为 `.env` 后,可使用 `docker compose --env-file .env -f deploy/compose/compose.yaml up -d` 启动基础依赖。 + +本地 PostgreSQL 暴露在 `127.0.0.1:15432`,避免与系统或其他项目常用的 `5432` 端口冲突。 + +后端测试: + +```bash +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 test +``` + +当前实施范围和验收标准见 [docs/product/mvp.md](docs/product/mvp.md)。 diff --git a/ai-service/README.md b/ai-service/README.md new file mode 100644 index 0000000..01bcaba --- /dev/null +++ b/ai-service/README.md @@ -0,0 +1,3 @@ +# AI Service + +Python 3.11+、FastAPI 和 LangGraph。首期仅处理自然语言到请假草稿的结构化转换,以及流程进度查询规划;不拥有业务写权限。 diff --git a/backend/.kotlin/sessions/kotlin-compiler-2119854500868872929.salive b/backend/.kotlin/sessions/kotlin-compiler-2119854500868872929.salive new file mode 100644 index 0000000..e69de29 diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..281ea8f --- /dev/null +++ b/backend/README.md @@ -0,0 +1,42 @@ +# Backend + +Kotlin + Spring Boot 模块化单体。计划模块:`boot`、`identity`、`organization`、`authorization`、`workflow`、`form`、`approval`、`document`、`notification`、`ai-orchestration` 和 `audit`。 + +模块必须通过应用接口协作,不能直接访问其他模块拥有的表。 + +## 本地构建 + +```bash +export JAVA_HOME=/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home +export GRADLE_USER_HOME=/tmp/aioa-gradle-home +./gradlew test +``` + +本机默认 `~/.gradle` 原生缓存存在加载问题,因此当前建议为项目构建显式设置 `GRADLE_USER_HOME`。 + +## 当前能力 + +- Spring Boot 3.5.3 / Kotlin 2.1.21 / JDK 21 +- OAuth2 Resource Server,健康检查匿名可访问,业务 API 默认要求认证 +- `X-Trace-Id` 接收、校验、生成、响应回传与日志上下文 +- RFC 9457 `application/problem+json` 风格的统一异常处理 +- PostgreSQL、jOOQ 与 Flyway +- 基础 Schema 和审计事件表迁移 +- 租户、用户、部门、岗位、任职关系和业务角色模型 +- `/api/v1/me` 根据 JWT 租户与 Subject 查询 OA 权威用户数据 + +## 开发身份 + +本地 Keycloak Realm 为 `aioa`,移动端公共客户端为 `aioa-mobile`,启用 Authorization Code + PKCE。为了便于本地联调,还暂时启用了 Direct Access Grant。 +开发环境的 OIDC Issuer 固定为 `http://localhost:8081/realms/aioa`;客户端和后端不能混用 `127.0.0.1`,因为 JWT Issuer 必须完全一致。 + +| 用户名 | 密码 | 角色 | +|---|---|---| +| `employee` | `Employee123!` | 普通员工 | +| `manager` | `Manager123!` | 员工、部门主管 | +| `admin` | `Admin123!` | 员工、OA 管理员 | + +这些凭据只允许用于本地开发,生产 Realm 不得导入测试用户,也不得启用密码模式。 + +PostgreSQL 使用版本化开发卷 `postgres-data-v1`,避免复用其他项目或旧凭据初始化的数据目录。切换卷版本不会删除旧卷。 +开发数据库通过宿主机端口 `15432` 访问,容器内部仍使用 PostgreSQL 默认端口 `5432`。 diff --git a/backend/boot/build.gradle.kts b/backend/boot/build.gradle.kts new file mode 100644 index 0000000..ab7ed02 --- /dev/null +++ b/backend/boot/build.gradle.kts @@ -0,0 +1,40 @@ +plugins { + id("org.springframework.boot") + id("io.spring.dependency-management") + kotlin("jvm") + kotlin("plugin.spring") +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +kotlin { + compilerOptions { + freeCompilerArgs.addAll("-Xjsr305=strict") + } +} + +dependencies { + implementation("org.springframework.boot:spring-boot-starter-actuator") + implementation("org.springframework.boot:spring-boot-starter-jooq") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + implementation("org.springframework.boot:spring-boot-starter-validation") + implementation("org.springframework.boot:spring-boot-starter-web") + implementation("com.fasterxml.jackson.module:jackson-module-kotlin") + implementation("org.flywaydb:flyway-core") + implementation("org.flywaydb:flyway-database-postgresql") + implementation("org.jetbrains.kotlin:kotlin-reflect") + runtimeOnly("org.postgresql:postgresql") + + testImplementation("org.springframework.boot:spring-boot-starter-test") + testImplementation("org.springframework.security:spring-security-test") + testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") + testRuntimeOnly("org.junit.platform:junit-platform-launcher") +} + +tasks.withType { + useJUnitPlatform() +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt new file mode 100644 index 0000000..d9e0eb2 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt @@ -0,0 +1,11 @@ +package com.all8ai.aioa + +import org.springframework.boot.autoconfigure.SpringBootApplication +import org.springframework.boot.runApplication + +@SpringBootApplication +class AioaApplication + +fun main(args: Array) { + runApplication(*args) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt new file mode 100644 index 0000000..a44c608 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt @@ -0,0 +1,46 @@ +package com.all8ai.aioa.identity.api + +import com.all8ai.aioa.identity.application.CurrentUserService +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 +import java.util.UUID + +@RestController +@RequestMapping("/api/v1/me") +class CurrentUserController( + private val currentUserService: CurrentUserService, +) { + @GetMapping + fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse { + val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")) + return CurrentUserResponse( + id = currentUser.id, + tenantId = currentUser.tenantId, + username = currentUser.username, + displayName = currentUser.displayName, + email = currentUser.email, + department = currentUser.department?.let { OrganizationRef(it.id, it.name) }, + position = currentUser.position?.let { OrganizationRef(it.id, it.name) }, + roles = currentUser.roles, + ) + } +} + +data class CurrentUserResponse( + val id: UUID, + val tenantId: UUID, + val username: String, + val displayName: String, + val email: String?, + val department: OrganizationRef?, + val position: OrganizationRef?, + val roles: Set, +) + +data class OrganizationRef( + val id: UUID, + val name: String, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/application/CurrentUserService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/application/CurrentUserService.kt new file mode 100644 index 0000000..3bc9732 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/application/CurrentUserService.kt @@ -0,0 +1,27 @@ +package com.all8ai.aioa.identity.application + +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.identity.domain.CurrentUserRepository +import com.all8ai.aioa.shared.web.ApiException +import org.springframework.http.HttpStatus +import org.springframework.stereotype.Service +import java.util.UUID + +@Service +class CurrentUserService( + private val repository: CurrentUserRepository, +) { + fun get(subject: String, tenantClaim: String?): CurrentUser { + val tenantId = tenantClaim?.let(::parseTenantId) + ?: throw ApiException(HttpStatus.FORBIDDEN, "TENANT_CLAIM_MISSING", "登录身份缺少租户信息") + + return repository.findActiveBySubject(tenantId, subject) + ?: throw ApiException(HttpStatus.FORBIDDEN, "USER_NOT_PROVISIONED", "当前用户尚未同步到 OA 系统") + } + + private fun parseTenantId(value: String): UUID = try { + UUID.fromString(value) + } catch (_: IllegalArgumentException) { + throw ApiException(HttpStatus.FORBIDDEN, "TENANT_CLAIM_INVALID", "登录身份中的租户信息无效") + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/domain/CurrentUser.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/domain/CurrentUser.kt new file mode 100644 index 0000000..2866a47 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/domain/CurrentUser.kt @@ -0,0 +1,23 @@ +package com.all8ai.aioa.identity.domain + +import java.util.UUID + +data class CurrentUser( + val id: UUID, + val tenantId: UUID, + val username: String, + val displayName: String, + val email: String?, + val department: OrganizationRef?, + val position: OrganizationRef?, + val roles: Set, +) + +data class OrganizationRef( + val id: UUID, + val name: String, +) + +fun interface CurrentUserRepository { + fun findActiveBySubject(tenantId: UUID, subject: String): CurrentUser? +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/infrastructure/JooqCurrentUserRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/infrastructure/JooqCurrentUserRepository.kt new file mode 100644 index 0000000..84b7993 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/infrastructure/JooqCurrentUserRepository.kt @@ -0,0 +1,77 @@ +package com.all8ai.aioa.identity.infrastructure + +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.identity.domain.CurrentUserRepository +import com.all8ai.aioa.identity.domain.OrganizationRef +import org.jooq.DSLContext +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +class JooqCurrentUserRepository( + private val dsl: DSLContext, +) : CurrentUserRepository { + override fun findActiveBySubject(tenantId: UUID, subject: String): CurrentUser? { + val record = dsl.fetchOne( + """ + SELECT + u.id, + u.tenant_id, + u.username, + u.display_name, + u.email, + d.id AS department_id, + d.name AS department_name, + p.id AS position_id, + p.name AS position_name + 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_from <= CURRENT_TIMESTAMP + AND (a.effective_until IS NULL OR a.effective_until > CURRENT_TIMESTAMP) + LEFT JOIN organization.department d ON d.id = a.department_id + LEFT JOIN organization.position p ON p.id = a.position_id + WHERE u.tenant_id = ? + AND u.keycloak_subject = ? + AND u.status = 'ACTIVE' + """.trimIndent(), + tenantId, + subject, + ) ?: return null + + val userId = record.get("id", UUID::class.java)!! + val roles = dsl.fetch( + """ + SELECT r.code + FROM authz.user_role ur + JOIN authz.role r + ON r.tenant_id = ur.tenant_id + AND r.id = ur.role_id + WHERE ur.tenant_id = ? + AND ur.user_id = ? + AND ur.effective_from <= CURRENT_TIMESTAMP + AND (ur.effective_until IS NULL OR ur.effective_until > CURRENT_TIMESTAMP) + AND r.status = 'ACTIVE' + ORDER BY r.code + """.trimIndent(), + tenantId, + userId, + ).mapNotNull { it.get("code", String::class.java) }.toSortedSet() + + return CurrentUser( + id = userId, + tenantId = record.get("tenant_id", UUID::class.java)!!, + username = record.get("username", String::class.java)!!, + displayName = record.get("display_name", String::class.java)!!, + email = record.get("email", String::class.java), + department = ref(record.get("department_id", UUID::class.java), record.get("department_name", String::class.java)), + position = ref(record.get("position_id", UUID::class.java), record.get("position_name", String::class.java)), + roles = roles, + ) + } + + private fun ref(id: UUID?, name: String?): OrganizationRef? = + if (id != null && name != null) OrganizationRef(id, name) else null +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/SecurityConfiguration.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/SecurityConfiguration.kt new file mode 100644 index 0000000..e9d6249 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/SecurityConfiguration.kt @@ -0,0 +1,20 @@ +package com.all8ai.aioa.shared.security + +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.security.config.Customizer.withDefaults +import org.springframework.security.config.annotation.web.builders.HttpSecurity +import org.springframework.security.web.SecurityFilterChain + +@Configuration +class SecurityConfiguration { + @Bean + fun securityFilterChain(http: HttpSecurity): SecurityFilterChain = http + .csrf { it.disable() } + .authorizeHttpRequests { + it.requestMatchers("/actuator/health", "/actuator/info").permitAll() + .anyRequest().authenticated() + } + .oauth2ResourceServer { it.jwt(withDefaults()) } + .build() +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/ApiException.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/ApiException.kt new file mode 100644 index 0000000..ffcc74f --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/ApiException.kt @@ -0,0 +1,9 @@ +package com.all8ai.aioa.shared.web + +import org.springframework.http.HttpStatus + +class ApiException( + val status: HttpStatus, + val code: String, + override val message: String, +) : RuntimeException(message) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt new file mode 100644 index 0000000..fa16956 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/GlobalExceptionHandler.kt @@ -0,0 +1,48 @@ +package com.all8ai.aioa.shared.web + +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.ConstraintViolationException +import org.slf4j.LoggerFactory +import org.slf4j.MDC +import org.springframework.http.HttpStatus +import org.springframework.http.ProblemDetail +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.MethodArgumentNotValidException +import org.springframework.web.bind.annotation.ExceptionHandler +import org.springframework.web.bind.annotation.RestControllerAdvice +import java.net.URI + +@RestControllerAdvice +class GlobalExceptionHandler { + private val logger = LoggerFactory.getLogger(javaClass) + + @ExceptionHandler(ApiException::class) + fun handleApiException(exception: ApiException, request: HttpServletRequest): ResponseEntity = + response(exception.status, exception.code, exception.message, request) + + @ExceptionHandler(MethodArgumentNotValidException::class, ConstraintViolationException::class) + fun handleValidation(exception: Exception, request: HttpServletRequest): ResponseEntity = + response(HttpStatus.BAD_REQUEST, "REQUEST_INVALID", exception.message ?: "请求参数无效", request) + + @ExceptionHandler(Exception::class) + fun handleUnexpected(exception: Exception, request: HttpServletRequest): ResponseEntity { + logger.error("Unhandled request error", exception) + return response(HttpStatus.INTERNAL_SERVER_ERROR, "INTERNAL_ERROR", "服务器处理请求失败", request) + } + + private fun response( + status: HttpStatus, + code: String, + detail: String, + request: HttpServletRequest, + ): ResponseEntity { + val problem = ProblemDetail.forStatusAndDetail(status, detail).apply { + type = URI.create("https://aioa.all8ai.com/problems/${code.lowercase().replace('_', '-')}") + title = status.reasonPhrase + instance = URI.create(request.requestURI) + setProperty("code", code) + setProperty("traceId", MDC.get(TraceIdFilter.MDC_TRACE_ID) ?: "unknown") + } + return ResponseEntity.status(status).body(problem) + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/TraceIdFilter.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/TraceIdFilter.kt new file mode 100644 index 0000000..0781b73 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/web/TraceIdFilter.kt @@ -0,0 +1,39 @@ +package com.all8ai.aioa.shared.web + +import jakarta.servlet.FilterChain +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse +import org.slf4j.MDC +import org.springframework.core.Ordered +import org.springframework.core.annotation.Order +import org.springframework.stereotype.Component +import org.springframework.web.filter.OncePerRequestFilter +import java.util.UUID + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +class TraceIdFilter : OncePerRequestFilter() { + override fun doFilterInternal( + request: HttpServletRequest, + response: HttpServletResponse, + filterChain: FilterChain, + ) { + val traceId = request.getHeader(TRACE_ID_HEADER) + ?.takeIf { TRACE_ID_PATTERN.matches(it) } + ?: UUID.randomUUID().toString() + + MDC.put(MDC_TRACE_ID, traceId) + response.setHeader(TRACE_ID_HEADER, traceId) + try { + filterChain.doFilter(request, response) + } finally { + MDC.remove(MDC_TRACE_ID) + } + } + + companion object { + const val TRACE_ID_HEADER = "X-Trace-Id" + const val MDC_TRACE_ID = "traceId" + private val TRACE_ID_PATTERN = Regex("[A-Za-z0-9._:-]{8,128}") + } +} diff --git a/backend/boot/src/main/resources/application.yaml b/backend/boot/src/main/resources/application.yaml new file mode 100644 index 0000000..01e44e9 --- /dev/null +++ b/backend/boot/src/main/resources/application.yaml @@ -0,0 +1,40 @@ +spring: + application: + name: aioa-backend + datasource: + url: ${DB_URL:jdbc:postgresql://127.0.0.1:15432/aioa} + username: ${DB_USER:aioa} + password: ${DB_PASSWORD:change-me} + hikari: + maximum-pool-size: ${DB_POOL_SIZE:10} + minimum-idle: 1 + flyway: + enabled: true + locations: classpath:db/migration + jooq: + sql-dialect: postgres + security: + oauth2: + resourceserver: + jwt: + issuer-uri: ${OIDC_ISSUER_URI:http://localhost:8081/realms/aioa} + +server: + port: ${SERVER_PORT:8080} + shutdown: graceful + error: + include-message: never + +management: + endpoints: + web: + exposure: + include: health,info,prometheus + endpoint: + health: + probes: + enabled: true + +logging: + pattern: + correlation: "[traceId=%X{traceId:-}] " diff --git a/backend/boot/src/main/resources/db/migration/V1__create_base_schemas.sql b/backend/boot/src/main/resources/db/migration/V1__create_base_schemas.sql new file mode 100644 index 0000000..7fee1bd --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V1__create_base_schemas.sql @@ -0,0 +1,30 @@ +CREATE SCHEMA IF NOT EXISTS identity; +CREATE SCHEMA IF NOT EXISTS organization; +CREATE SCHEMA IF NOT EXISTS authz; +CREATE SCHEMA IF NOT EXISTS workflow; +CREATE SCHEMA IF NOT EXISTS form; +CREATE SCHEMA IF NOT EXISTS business; +CREATE SCHEMA IF NOT EXISTS knowledge; +CREATE SCHEMA IF NOT EXISTS integration; +CREATE SCHEMA IF NOT EXISTS audit; +CREATE SCHEMA IF NOT EXISTS flowable; + +CREATE TABLE audit.event ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL, + actor_id UUID NOT NULL, + action VARCHAR(120) NOT NULL, + resource_type VARCHAR(120) NOT NULL, + resource_id VARCHAR(200), + trace_id VARCHAR(128) NOT NULL, + idempotency_key VARCHAR(128), + result VARCHAR(32) NOT NULL, + occurred_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + details JSONB NOT NULL DEFAULT '{}'::jsonb +); + +CREATE INDEX idx_audit_event_tenant_time + ON audit.event (tenant_id, occurred_at DESC); + +CREATE INDEX idx_audit_event_trace + ON audit.event (trace_id); diff --git a/backend/boot/src/main/resources/db/migration/V2__create_identity_and_organization.sql b/backend/boot/src/main/resources/db/migration/V2__create_identity_and_organization.sql new file mode 100644 index 0000000..f0fcc45 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V2__create_identity_and_organization.sql @@ -0,0 +1,103 @@ +CREATE TABLE identity.tenant ( + id UUID PRIMARY KEY, + code VARCHAR(64) NOT NULL UNIQUE, + name VARCHAR(200) NOT NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0 +); + +CREATE TABLE organization.department ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + parent_id UUID REFERENCES organization.department(id), + code VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE (tenant_id, code), + UNIQUE (tenant_id, id) +); + +CREATE TABLE organization.position ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + code VARCHAR(64) NOT NULL, + name VARCHAR(200) NOT NULL, + status VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE (tenant_id, code), + UNIQUE (tenant_id, id) +); + +CREATE TABLE identity.user_account ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + keycloak_subject VARCHAR(100) NOT NULL, + username VARCHAR(100) NOT NULL, + display_name VARCHAR(200) NOT NULL, + email VARCHAR(320), + status VARCHAR(32) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + version BIGINT NOT NULL DEFAULT 0, + UNIQUE (tenant_id, keycloak_subject), + UNIQUE (tenant_id, username), + UNIQUE (tenant_id, id) +); + +CREATE TABLE organization.user_assignment ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + user_id UUID NOT NULL, + department_id UUID NOT NULL, + position_id UUID, + is_primary BOOLEAN NOT NULL DEFAULT FALSE, + effective_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + effective_until TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_assignment_user FOREIGN KEY (tenant_id, user_id) + REFERENCES identity.user_account(tenant_id, id), + CONSTRAINT fk_assignment_department FOREIGN KEY (tenant_id, department_id) + REFERENCES organization.department(tenant_id, id), + CONSTRAINT fk_assignment_position FOREIGN KEY (tenant_id, position_id) + REFERENCES organization.position(tenant_id, id) +); + +CREATE UNIQUE INDEX uq_user_primary_assignment + ON organization.user_assignment (tenant_id, user_id) + WHERE is_primary = TRUE AND effective_until IS NULL; + +CREATE TABLE authz.role ( + id UUID PRIMARY KEY, + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + code VARCHAR(100) NOT NULL, + name VARCHAR(200) NOT NULL, + status VARCHAR(32) NOT NULL, + UNIQUE (tenant_id, code), + UNIQUE (tenant_id, id) +); + +CREATE TABLE authz.user_role ( + tenant_id UUID NOT NULL REFERENCES identity.tenant(id), + user_id UUID NOT NULL, + role_id UUID NOT NULL, + effective_from TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + effective_until TIMESTAMPTZ, + PRIMARY KEY (tenant_id, user_id, role_id), + CONSTRAINT fk_user_role_user FOREIGN KEY (tenant_id, user_id) + REFERENCES identity.user_account(tenant_id, id), + CONSTRAINT fk_user_role_role FOREIGN KEY (tenant_id, role_id) + REFERENCES authz.role(tenant_id, id) +); + +CREATE INDEX idx_user_account_subject + ON identity.user_account (tenant_id, keycloak_subject); + +CREATE INDEX idx_assignment_department + ON organization.user_assignment (tenant_id, department_id); diff --git a/backend/boot/src/main/resources/db/migration/V3__seed_development_identity.sql b/backend/boot/src/main/resources/db/migration/V3__seed_development_identity.sql new file mode 100644 index 0000000..ca45d00 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V3__seed_development_identity.sql @@ -0,0 +1,37 @@ +INSERT INTO identity.tenant (id, code, name, status) +VALUES ('00000000-0000-7000-8000-000000000001', 'demo', 'AIOA 演示组织', 'ACTIVE'); + +INSERT INTO organization.department (id, tenant_id, code, name, status) +VALUES ('20000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000001', 'product', '产品研发部', 'ACTIVE'); + +INSERT INTO organization.position (id, tenant_id, code, name, status) +VALUES + ('30000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000001', 'employee', '员工', 'ACTIVE'), + ('30000000-0000-7000-8000-000000000002', '00000000-0000-7000-8000-000000000001', 'manager', '部门主管', 'ACTIVE'), + ('30000000-0000-7000-8000-000000000003', '00000000-0000-7000-8000-000000000001', 'oa-admin', 'OA 管理员', 'ACTIVE'); + +INSERT INTO identity.user_account (id, tenant_id, keycloak_subject, username, display_name, email, status) +VALUES + ('40000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000001', '10000000-0000-7000-8000-000000000001', 'employee', '员工小明', 'employee@example.local', 'ACTIVE'), + ('40000000-0000-7000-8000-000000000002', '00000000-0000-7000-8000-000000000001', '10000000-0000-7000-8000-000000000002', 'manager', '王主管', 'manager@example.local', 'ACTIVE'), + ('40000000-0000-7000-8000-000000000003', '00000000-0000-7000-8000-000000000001', '10000000-0000-7000-8000-000000000003', 'admin', 'OA 管理员', 'admin@example.local', 'ACTIVE'); + +INSERT INTO organization.user_assignment (id, tenant_id, user_id, department_id, position_id, is_primary) +VALUES + ('50000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000001', '20000000-0000-7000-8000-000000000001', '30000000-0000-7000-8000-000000000001', TRUE), + ('50000000-0000-7000-8000-000000000002', '00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000002', '20000000-0000-7000-8000-000000000001', '30000000-0000-7000-8000-000000000002', TRUE), + ('50000000-0000-7000-8000-000000000003', '00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000003', '20000000-0000-7000-8000-000000000001', '30000000-0000-7000-8000-000000000003', TRUE); + +INSERT INTO authz.role (id, tenant_id, code, name, status) +VALUES + ('60000000-0000-7000-8000-000000000001', '00000000-0000-7000-8000-000000000001', 'employee', '普通员工', 'ACTIVE'), + ('60000000-0000-7000-8000-000000000002', '00000000-0000-7000-8000-000000000001', 'department_manager', '部门主管', 'ACTIVE'), + ('60000000-0000-7000-8000-000000000003', '00000000-0000-7000-8000-000000000001', 'oa_admin', 'OA 管理员', 'ACTIVE'); + +INSERT INTO authz.user_role (tenant_id, user_id, role_id) +VALUES + ('00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000001', '60000000-0000-7000-8000-000000000001'), + ('00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000002', '60000000-0000-7000-8000-000000000001'), + ('00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000002', '60000000-0000-7000-8000-000000000002'), + ('00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000003', '60000000-0000-7000-8000-000000000001'), + ('00000000-0000-7000-8000-000000000001', '40000000-0000-7000-8000-000000000003', '60000000-0000-7000-8000-000000000003'); diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/identity/application/CurrentUserServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/identity/application/CurrentUserServiceTest.kt new file mode 100644 index 0000000..4c0df09 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/identity/application/CurrentUserServiceTest.kt @@ -0,0 +1,55 @@ +package com.all8ai.aioa.identity.application + +import com.all8ai.aioa.identity.domain.CurrentUser +import com.all8ai.aioa.identity.domain.CurrentUserRepository +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.springframework.http.HttpStatus +import java.util.UUID + +class CurrentUserServiceTest { + private val tenantId = UUID.fromString("00000000-0000-7000-8000-000000000001") + + @Test + fun `loads provisioned user within claimed tenant`() { + val expected = CurrentUser( + id = UUID.randomUUID(), + tenantId = tenantId, + username = "employee", + displayName = "员工小明", + email = null, + department = null, + position = null, + roles = setOf("employee"), + ) + val service = CurrentUserService(CurrentUserRepository { actualTenant, subject -> + expected.takeIf { actualTenant == tenantId && subject == "subject-1" } + }) + + assertThat(service.get("subject-1", tenantId.toString())).isEqualTo(expected) + } + + @Test + fun `rejects missing tenant claim`() { + val service = CurrentUserService(CurrentUserRepository { _, _ -> null }) + + assertThatThrownBy { service.get("subject-1", null) } + .isInstanceOfSatisfying(ApiException::class.java) { + assertThat(it.status).isEqualTo(HttpStatus.FORBIDDEN) + assertThat(it.code).isEqualTo("TENANT_CLAIM_MISSING") + } + } + + @Test + fun `rejects user not provisioned in claimed tenant`() { + val service = CurrentUserService(CurrentUserRepository { _, _ -> null }) + + assertThatThrownBy { service.get("unknown", tenantId.toString()) } + .isInstanceOfSatisfying(ApiException::class.java) { + assertThat(it.status).isEqualTo(HttpStatus.FORBIDDEN) + assertThat(it.code).isEqualTo("USER_NOT_PROVISIONED") + } + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/web/TraceIdFilterTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/web/TraceIdFilterTest.kt new file mode 100644 index 0000000..1be7f14 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/web/TraceIdFilterTest.kt @@ -0,0 +1,36 @@ +package com.all8ai.aioa.shared.web + +import org.assertj.core.api.Assertions.assertThat +import org.junit.jupiter.api.Test +import org.springframework.mock.web.MockFilterChain +import org.springframework.mock.web.MockHttpServletRequest +import org.springframework.mock.web.MockHttpServletResponse + +class TraceIdFilterTest { + private val filter = TraceIdFilter() + + @Test + fun `preserves a valid client trace id`() { + val request = MockHttpServletRequest().apply { + addHeader(TraceIdFilter.TRACE_ID_HEADER, "client-trace-123") + } + val response = MockHttpServletResponse() + + filter.doFilter(request, response, MockFilterChain()) + + assertThat(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)).isEqualTo("client-trace-123") + } + + @Test + fun `replaces an invalid client trace id`() { + val request = MockHttpServletRequest().apply { + addHeader(TraceIdFilter.TRACE_ID_HEADER, "bad value") + } + val response = MockHttpServletResponse() + + filter.doFilter(request, response, MockFilterChain()) + + assertThat(response.getHeader(TraceIdFilter.TRACE_ID_HEADER)) + .matches("[0-9a-f-]{36}") + } +} diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts new file mode 100644 index 0000000..5af5e20 --- /dev/null +++ b/backend/build.gradle.kts @@ -0,0 +1,11 @@ +plugins { + id("org.springframework.boot") version "3.5.3" apply false + id("io.spring.dependency-management") version "1.1.7" apply false + kotlin("jvm") version "2.1.21" apply false + kotlin("plugin.spring") version "2.1.21" apply false +} + +allprojects { + group = "com.all8ai.aioa" + version = "0.1.0-SNAPSHOT" +} diff --git a/backend/gradle.properties b/backend/gradle.properties new file mode 100644 index 0000000..a1920cd --- /dev/null +++ b/backend/gradle.properties @@ -0,0 +1,4 @@ +org.gradle.configuration-cache=true +org.gradle.caching=true +org.gradle.parallel=true +kotlin.code.style=official diff --git a/backend/gradle/wrapper/gradle-wrapper.jar b/backend/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/backend/gradle/wrapper/gradle-wrapper.properties b/backend/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..fec70bd --- /dev/null +++ b/backend/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/backend/gradlew b/backend/gradlew new file mode 100755 index 0000000..b9bb139 --- /dev/null +++ b/backend/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/backend/gradlew.bat b/backend/gradlew.bat new file mode 100644 index 0000000..aa5f10b --- /dev/null +++ b/backend/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/backend/settings.gradle.kts b/backend/settings.gradle.kts new file mode 100644 index 0000000..cf44fd5 --- /dev/null +++ b/backend/settings.gradle.kts @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + mavenCentral() + } +} + +rootProject.name = "aioa-backend" +include("boot") diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml new file mode 100644 index 0000000..9a986e1 --- /dev/null +++ b/contracts/openapi/aioa-v1.yaml @@ -0,0 +1,138 @@ +openapi: 3.1.0 +info: + title: AIOA API + version: 0.1.0 +servers: + - url: /api/v1 +paths: + /me: + get: + operationId: getCurrentUser + summary: 获取当前登录用户 + responses: + "200": + description: 当前用户 + content: + application/json: + schema: + $ref: "#/components/schemas/CurrentUser" + "401": + $ref: "#/components/responses/Unauthorized" + /leave-requests: + post: + operationId: createLeaveRequest + summary: 创建并发起请假申请 + parameters: + - $ref: "#/components/parameters/IdempotencyKey" + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CreateLeaveRequest" + responses: + "201": + description: 已创建 + content: + application/json: + schema: + $ref: "#/components/schemas/LeaveRequest" + "400": + $ref: "#/components/responses/BadRequest" + "401": + $ref: "#/components/responses/Unauthorized" + "403": + $ref: "#/components/responses/Forbidden" +components: + parameters: + IdempotencyKey: + name: Idempotency-Key + in: header + required: true + schema: + type: string + minLength: 16 + maxLength: 128 + responses: + BadRequest: + description: 请求无效 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + Unauthorized: + description: 未认证 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + Forbidden: + description: 无权操作 + content: + application/problem+json: + schema: + $ref: "#/components/schemas/Problem" + schemas: + CurrentUser: + type: object + required: [id, tenantId, username, displayName, roles] + properties: + id: { type: string, format: uuid } + tenantId: { type: string, format: uuid } + username: { type: string } + displayName: { type: string } + email: + type: [string, "null"] + format: email + department: + oneOf: + - $ref: "#/components/schemas/OrganizationRef" + - type: "null" + position: + oneOf: + - $ref: "#/components/schemas/OrganizationRef" + - type: "null" + roles: + type: array + uniqueItems: true + items: { type: string } + OrganizationRef: + type: object + required: [id, name] + properties: + id: { type: string, format: uuid } + name: { type: string } + CreateLeaveRequest: + 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 } + attachmentIds: + type: array + maxItems: 10 + items: { type: string, format: uuid } + LeaveRequest: + type: object + required: [id, status, version, createdAt] + properties: + id: { type: string, format: uuid } + status: + type: string + enum: [PENDING, APPROVED, REJECTED, WITHDRAWN] + version: { type: integer, minimum: 0 } + createdAt: { type: string, format: date-time } + Problem: + type: object + required: [type, title, status, code, traceId] + properties: + type: { type: string, format: uri-reference } + title: { type: string } + status: { type: integer } + detail: { type: string } + code: { type: string } + traceId: { type: string } diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml new file mode 100644 index 0000000..31b8b95 --- /dev/null +++ b/deploy/compose/compose.yaml @@ -0,0 +1,57 @@ +name: aioa + +services: + postgres: + image: postgres:17.5-alpine + environment: + POSTGRES_DB: ${POSTGRES_DB:-aioa} + POSTGRES_USER: ${POSTGRES_USER:-aioa} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-change-me} + ports: + - "15432:5432" + volumes: + # Versioned volume avoids accidentally reusing a database initialized + # by another local project or an earlier credential set. + - postgres-data-v1:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 20 + + redis: + image: redis:8.0.2-alpine + command: ["redis-server", "--appendonly", "yes"] + ports: + - "6379:6379" + volumes: + - redis-data:/data + + keycloak: + image: quay.io/keycloak/keycloak:26.2.5 + command: ["start-dev", "--import-realm"] + environment: + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:-change-me} + KC_HOSTNAME: http://localhost:8081 + ports: + - "8081:8080" + volumes: + - ./keycloak/realm-aioa.json:/opt/keycloak/data/import/realm-aioa.json:ro + + minio: + image: minio/minio:RELEASE.2025-06-13T11-33-47Z + command: ["server", "/data", "--console-address", ":9001"] + environment: + MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin} + MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-change-me-now} + ports: + - "9000:9000" + - "9001:9001" + volumes: + - minio-data:/data + +volumes: + postgres-data-v1: + redis-data: + minio-data: diff --git a/deploy/compose/keycloak/realm-aioa.json b/deploy/compose/keycloak/realm-aioa.json new file mode 100644 index 0000000..faebc7a --- /dev/null +++ b/deploy/compose/keycloak/realm-aioa.json @@ -0,0 +1,84 @@ +{ + "realm": "aioa", + "enabled": true, + "displayName": "AIOA Development", + "registrationAllowed": false, + "resetPasswordAllowed": true, + "loginWithEmailAllowed": true, + "roles": { + "realm": [ + { "name": "employee", "description": "普通员工" }, + { "name": "department_manager", "description": "部门主管" }, + { "name": "oa_admin", "description": "OA 管理员" } + ] + }, + "clients": [ + { + "clientId": "aioa-mobile", + "name": "AIOA Mobile", + "enabled": true, + "publicClient": true, + "standardFlowEnabled": true, + "directAccessGrantsEnabled": true, + "redirectUris": ["aioa://oauth/callback", "http://localhost:*"], + "webOrigins": ["+"], + "attributes": { + "pkce.code.challenge.method": "S256" + }, + "protocolMappers": [ + { + "name": "tenant-id", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-attribute-mapper", + "consentRequired": false, + "config": { + "user.attribute": "tenant_id", + "claim.name": "tenant_id", + "jsonType.label": "String", + "id.token.claim": "true", + "access.token.claim": "true", + "userinfo.token.claim": "true" + } + } + ] + } + ], + "users": [ + { + "id": "10000000-0000-7000-8000-000000000001", + "username": "employee", + "enabled": true, + "emailVerified": true, + "firstName": "小明", + "lastName": "员工", + "email": "employee@example.local", + "attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] }, + "credentials": [{ "type": "password", "value": "Employee123!", "temporary": false }], + "realmRoles": ["employee"] + }, + { + "id": "10000000-0000-7000-8000-000000000002", + "username": "manager", + "enabled": true, + "emailVerified": true, + "firstName": "主管", + "lastName": "王", + "email": "manager@example.local", + "attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] }, + "credentials": [{ "type": "password", "value": "Manager123!", "temporary": false }], + "realmRoles": ["employee", "department_manager"] + }, + { + "id": "10000000-0000-7000-8000-000000000003", + "username": "admin", + "enabled": true, + "emailVerified": true, + "firstName": "管理员", + "lastName": "OA", + "email": "admin@example.local", + "attributes": { "tenant_id": ["00000000-0000-7000-8000-000000000001"] }, + "credentials": [{ "type": "password", "value": "Admin123!", "temporary": false }], + "realmRoles": ["employee", "oa_admin"] + } + ] +} diff --git a/docs/engineering/decisions.md b/docs/engineering/decisions.md new file mode 100644 index 0000000..d11bcbf --- /dev/null +++ b/docs/engineering/decisions.md @@ -0,0 +1,21 @@ +# 工程决策记录 + +## ADR-001:先交付纵向闭环 + +首个里程碑围绕请假审批实现端到端能力,不按技术层横向建设全部平台组件。 + +## ADR-002:模块化单体 + +核心后端以单部署单元运行;模块拥有自己的数据表,通过应用接口与领域事件协作。 + +## ADR-003:本地环境最小化 + +首期本地依赖为 PostgreSQL、Keycloak、Redis 和 MinIO。Kafka、OpenSearch、APISIX、Vault 和 Kubernetes 在出现对应验收需求时接入。 + +## ADR-004:契约优先 + +移动端和 AI 服务通过 OpenAPI 与 Kotlin 后端集成。AI 只返回结构化建议或工具调用请求,业务执行权始终属于 Kotlin 后端。 + +## ADR-005:环境基线 + +项目使用 JDK 21、Python 3.11+ 和与主机架构一致的 Flutter stable。生产依赖和镜像禁止使用浮动 `latest` 标签;发布生产环境前由 CI 将已验证镜像解析并固定到真实摘要。 diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md new file mode 100644 index 0000000..212cdb8 --- /dev/null +++ b/docs/engineering/roadmap.md @@ -0,0 +1,37 @@ +# 实施路线 + +## M0:工程基线 + +- [x] 仓库、目录、环境和编码约定 +- [x] 本地基础依赖配置 +- [x] OpenAPI 基础契约 +- [x] Kotlin 后端 Wrapper、测试和构建入口 +- [x] Trace ID、统一异常和数据库基础迁移 +- [ ] GitLab CI 格式检查、测试和构建流水线 + +## M1:身份与组织 + +- [x] Keycloak Realm、移动客户端和开发测试身份 +- [x] 当前用户 OA 数据查询 +- [x] 租户、部门、人员、岗位、任职关系和角色基础模型 +- [x] 统一鉴权、错误结构、Trace ID 和审计基础 +- [x] Keycloak 与后端容器端到端验证 +- [ ] 设备注册、撤销与远程注销 +- [ ] 数据范围与工具权限 + +## M2:请假审批闭环 + +- 表单草稿和版本 +- Flowable 流程发布与执行 +- 发起、待办、批准、驳回、撤回和时间线 +- 附件、通知、弱网恢复和幂等处理 + +## M3:AI 最小闭环 + +- 自然语言生成请假草稿 +- 查询本人流程进度 +- 确认卡片、工具鉴权和 AI 审计 + +## Definition of Done + +每项功能必须同时具备:权限校验、审计、自动化测试、契约更新、错误处理和最小可观测性。 diff --git a/docs/product/mvp.md b/docs/product/mvp.md new file mode 100644 index 0000000..49b332f --- /dev/null +++ b/docs/product/mvp.md @@ -0,0 +1,52 @@ +# MVP 产品范围 + +## 1. 目标 + +用一个可运行的请假审批闭环验证移动端、身份、组织、权限、Flowable、审计和 AI 工具调用边界。 + +## 2. 用户角色 + +| 角色 | 核心能力 | +|---|---| +| 员工 | 创建和查看本人申请;撤回满足条件的申请 | +| 部门主管 | 查看并处理分配给自己的审批任务 | +| OA 管理员 | 管理组织基础数据、查看流程定义和审计记录 | + +## 3. 主流程 + +1. 员工登录并创建请假草稿。 +2. 员工填写类型、起止时间、原因和附件。 +3. 服务端校验数据、权限和幂等键后发起流程。 +4. 部门主管收到待办并批准或驳回。 +5. 员工查看状态、审批意见和时间线。 +6. 每个关键动作写入不可由普通管理员修改的审计记录。 + +## 4. 业务规则 + +- 结束时间必须晚于开始时间。 +- 首期请假类型为事假、病假和年假。 +- 申请人不能审批自己的申请。 +- 只有当前任务处理人可以批准或驳回。 +- 已结束流程不能撤回;审批前允许申请人撤回。 +- 所有写请求必须携带 `Idempotency-Key`。 +- 状态更新必须使用版本号防止并发覆盖。 +- 流程启动后固定引用已发布的流程版本。 + +## 5. AI 能力 + +首期只提供: + +- 将自然语言解析为请假单草稿。 +- 查询当前用户请假申请的流程进度。 + +AI 不直接写数据库或调用 Flowable。任何发起申请的动作都必须由 Kotlin 后端重新鉴权、校验,并由用户确认。 + +## 6. 验收标准 + +- 员工能够在移动端完成申请发起并看到状态变化。 +- 主管能够收到并处理待办。 +- 越权读取和越权审批返回明确的 403 错误。 +- 同一幂等键重复提交不会产生两条申请或两个流程实例。 +- 断网草稿能够恢复,失败提交可安全重试。 +- 每次写操作都能通过 Trace ID 查询对应审计记录。 +- AI 生成的草稿在用户确认前不会触发业务写操作。 diff --git a/docs/product/permission-matrix.md b/docs/product/permission-matrix.md new file mode 100644 index 0000000..d00ed4b --- /dev/null +++ b/docs/product/permission-matrix.md @@ -0,0 +1,17 @@ +# MVP 权限矩阵 + +| 资源 / 操作 | 员工 | 部门主管 | OA 管理员 | +|---|---:|---:|---:| +| 查看本人资料 | 允许 | 允许 | 允许 | +| 查看组织通讯录 | 授权范围 | 本部门 | 全组织 | +| 创建请假申请 | 允许 | 允许 | 允许 | +| 查看本人申请 | 允许 | 允许 | 允许 | +| 查看部门成员申请 | 禁止 | 仅待办及授权范围 | 全组织 | +| 审批申请 | 禁止 | 仅本人当前任务 | 按显式授权 | +| 撤回申请 | 本人且流程未结束 | 本人且流程未结束 | 禁止代撤回 | +| 管理组织数据 | 禁止 | 禁止 | 允许 | +| 查看审计 | 禁止 | 禁止 | 脱敏查询 | +| AI 填写申请草稿 | 允许 | 允许 | 允许 | +| AI 发起申请 | 用户确认后 | 用户确认后 | 用户确认后 | + +后端授权模型为 RBAC + 数据范围 + ABAC。表格是产品规则,不替代服务端逐资源鉴权。 diff --git a/mobile/README.md b/mobile/README.md new file mode 100644 index 0000000..97a6f19 --- /dev/null +++ b/mobile/README.md @@ -0,0 +1,5 @@ +# Mobile + +Flutter 客户端采用 Feature-first + Clean Architecture,一级导航为“工作台、AI 助手、待办、我的”。 + +当前机器的 Flutter/Dart 可执行文件发生架构级崩溃,需要先安装与主机一致的稳定版本,然后运行 `flutter create` 生成原生平台目录。 diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..ac82058 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,5 @@ +# Scripts + +后续放置开发环境检查、契约生成、数据库迁移和本地启动脚本。脚本必须可重复运行,不得包含密钥。 + +`verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,验证健康检查、Token 获取和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。 diff --git a/scripts/verify-local-auth.sh b/scripts/verify-local-auth.sh new file mode 100755 index 0000000..0bd0091 --- /dev/null +++ b/scripts/verify-local-auth.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash + +set -euo pipefail + +keycloak_url="${KEYCLOAK_URL:-http://localhost:8081}" +backend_url="${BACKEND_URL:-http://localhost:8080}" + +curl --fail --silent --show-error "${backend_url}/actuator/health" | jq . + +for username in employee manager admin; do + case "${username}" in + employee) password='Employee123!' ;; + manager) password='Manager123!' ;; + admin) password='Admin123!' ;; + esac + + token="$({ + curl --fail --silent --show-error \ + -X POST "${keycloak_url}/realms/aioa/protocol/openid-connect/token" \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + --data-urlencode client_id=aioa-mobile \ + --data-urlencode grant_type=password \ + --data-urlencode username="${username}" \ + --data-urlencode password="${password}" + } | jq -r .access_token)" + + printf '%s\n' "${username}" + curl --fail --silent --show-error \ + "${backend_url}/api/v1/me" \ + -H "Authorization: Bearer ${token}" | jq . +done