commit bbb09ed56f6ae8d47cec7c6a502126488ca1238a Author: selfrelease Date: Sat Jul 18 08:05:45 2026 +0800 feat: establish AIOA identity and organization baseline 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 0000000..b1b8ef5 Binary files /dev/null and b/backend/gradle/wrapper/gradle-wrapper.jar differ 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