feat: complete leave approval MVP

This commit is contained in:
selfrelease
2026-07-18 19:20:07 +08:00
parent 2105fe3bac
commit 090a7e33ce
133 changed files with 7845 additions and 100 deletions
+6
View File
@@ -9,3 +9,9 @@ KEYCLOAK_ADMIN=admin
KEYCLOAK_ADMIN_PASSWORD=change-me
MINIO_ROOT_USER=minioadmin
MINIO_ROOT_PASSWORD=change-me-now
MINIO_ENDPOINT=http://127.0.0.1:9000
MINIO_BUCKET=aioa-attachments
QWEN_API_KEY=
QWEN_MODEL=qwen-plus
AI_SERVICE_URL=http://127.0.0.1:8000
FIREBASE_CREDENTIALS_FILE=
+5
View File
@@ -24,6 +24,11 @@ __pycache__/
.pytest_cache/
.mypy_cache/
.ruff_cache/
*.egg-info/
# Deployment credentials
firebase-service-account*.json
**/firebase-service-account*.json
# Local data
.local/
+121
View File
@@ -0,0 +1,121 @@
stages:
- verify
- build
default:
interruptible: true
retry:
max: 1
when:
- runner_system_failure
- stuck_or_timeout_failure
workflow:
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
- if: $CI_COMMIT_BRANCH
- if: $CI_COMMIT_TAG
backend-test:
stage: verify
image: eclipse-temurin:21-jdk
variables:
GRADLE_USER_HOME: $CI_PROJECT_DIR/.cache/gradle
cache:
key:
files:
- backend/gradle/wrapper/gradle-wrapper.properties
paths:
- .cache/gradle/caches/
- .cache/gradle/wrapper/
before_script:
- cd backend
- chmod +x gradlew
script:
- ./gradlew --no-daemon test
artifacts:
when: always
expire_in: 7 days
reports:
junit: backend/boot/build/test-results/test/*.xml
ai-service-test:
stage: verify
image: python:3.12-slim
variables:
PIP_CACHE_DIR: $CI_PROJECT_DIR/.cache/pip
QWEN_API_KEY: ""
cache:
key:
files:
- ai-service/pyproject.toml
paths:
- .cache/pip/
before_script:
- cd ai-service
- python -m pip install --upgrade pip
- python -m pip install -e '.[dev]'
script:
- python -m compileall -q app tests
- mkdir -p reports
- pytest --junitxml=reports/pytest.xml
artifacts:
when: always
expire_in: 7 days
reports:
junit: ai-service/reports/pytest.xml
flutter-verify:
stage: verify
image: ghcr.io/cirruslabs/flutter:stable
variables:
PUB_CACHE: $CI_PROJECT_DIR/.cache/pub
cache:
key:
files:
- mobile/pubspec.lock
paths:
- .cache/pub/
before_script:
- cd mobile
- flutter pub get
script:
- dart format --output=none --set-exit-if-changed lib test
- flutter analyze
- mkdir -p reports
- flutter test --machine > reports/flutter-test.json
after_script:
- test -f mobile/reports/flutter-test.json || true
artifacts:
when: always
expire_in: 7 days
paths:
- mobile/reports/flutter-test.json
android-debug-build:
stage: build
image: ghcr.io/cirruslabs/flutter:stable
needs:
- flutter-verify
variables:
PUB_CACHE: $CI_PROJECT_DIR/.cache/pub
cache:
key:
files:
- mobile/pubspec.lock
paths:
- .cache/pub/
- mobile/.gradle/
before_script:
- cd mobile
- flutter pub get
script:
- flutter build apk --debug
artifacts:
expire_in: 7 days
paths:
- mobile/build/app/outputs/flutter-apk/app-debug.apk
# iOS 构建需要项目自行配置带 Xcode 和 Flutter 的 macOS GitLab Runner。
# 配置后可复制 flutter-verify 作业,并使用 tags: [macos] 及:
# flutter build ios --simulator --no-codesign
+37
View File
@@ -38,6 +38,14 @@ scripts/ 开发辅助脚本
本地 PostgreSQL 暴露在 `127.0.0.1:15432`,避免与系统或其他项目常用的 `5432` 端口冲突。
千问 AI 服务通过 `QWEN_API_KEY` 环境变量读取密钥。密钥只能放在被 Git 忽略的本地 `.env` 或部署密钥系统中,不得写入 `.env.example`、代码、镜像或 Flutter 客户端。启动 AI 服务:
```bash
docker compose --env-file .env -f deploy/compose/compose.yaml up -d ai-service
```
Android 模拟器登录前运行 `scripts/dev-android-reverse.sh`,使 Keycloak 的固定开发 Issuer `http://localhost:8081/realms/aioa` 与 Android 回调环境保持一致,并允许访问 MinIO 预签名地址。iOS Simulator 可直接使用默认本机地址。
后端测试:
```bash
@@ -49,4 +57,33 @@ export GRADLE_USER_HOME=/tmp/aioa-gradle-home
当前实施范围和验收标准见 [docs/product/mvp.md](docs/product/mvp.md)。
一键执行本地提交前验证:
```bash
./scripts/verify-all.sh
```
需要同时生成 Android 和 iOS Simulator 构建产物时:
```bash
AIOA_FULL_BUILD=1 ./scripts/verify-all.sh
```
Flutter 已实现 Schema 驱动的请假表单卡片演示,详见 [docs/engineering/mobile-schema-forms.md](docs/engineering/mobile-schema-forms.md)。
## 持续集成
仓库根目录的 `.gitlab-ci.yml` 默认执行:
- JDK 21 后端测试,并上传 JUnit 报告;
- Python 3.12 AI 服务编译检查与测试;
- Flutter 格式检查、静态分析和测试;
- Android Debug APK 构建与产物归档。
流水线不需要数据库、Keycloak 或千问密钥。iOS 构建需要带 Xcode 的 macOS GitLab Runner,配置 Runner 后按流水线文件末尾的说明启用。
## 移动推送
移动端使用 Firebase Cloud Messaging,同时承载 Android FCM 与 iOS APNs。生产构建需要分别提供 Firebase 项目的 `google-services.json``GoogleService-Info.plist`,后端通过 `FIREBASE_CREDENTIALS_FILE` 指向 Firebase Admin 服务账号 JSON。凭证文件必须由部署密钥系统挂载,不得提交到仓库。
未配置 Firebase 时,Flutter 会自动降级,站内通知仍然可用;后端推送 outbox 会保留并延迟重试。FlutterFire 当前最低支持 iOS 15,因此项目部署目标已统一调整为 iOS 15。
+4
View File
@@ -0,0 +1,4 @@
.venv
__pycache__
.pytest_cache
tests
+9
View File
@@ -0,0 +1,9 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY app ./app
RUN pip install --no-cache-dir .
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
+12 -1
View File
@@ -1,3 +1,14 @@
# AI Service
Python 3.11+、FastAPI 和 LangGraph。首期仅处理自然语言到请假草稿的结构化转换,以及流程进度查询规划;不拥有业务写权限。
FastAPI 服务负责把自然语言转换为受约束的请假草稿建议。当前使用千问 OpenAI 兼容接口,但不拥有数据库、Flowable 或业务写权限。
```bash
cd ai-service
python3 -m venv .venv
source .venv/bin/activate
pip install -e '.[dev]'
export QWEN_API_KEY='...'
uvicorn app.main:app --reload --port 8000
```
接口:`POST /v1/leave-drafts/suggest`。模型输出会经过 Pydantic 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。
+1
View File
@@ -0,0 +1 @@
"""AIOA AI service."""
+17
View File
@@ -0,0 +1,17 @@
from functools import lru_cache
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
qwen_api_key: str = ""
qwen_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
qwen_model: str = "qwen-plus"
request_timeout_seconds: float = 20.0
@lru_cache
def get_settings() -> Settings:
return Settings()
+57
View File
@@ -0,0 +1,57 @@
from fastapi import Depends, FastAPI, HTTPException
from app.config import get_settings
from app.models import LeaveDraftSuggestionRequest, LeaveDraftSuggestionResponse, LeaveProgressAnswerRequest, LeaveProgressAnswerResponse
from app.qwen import (
QwenConfigurationError,
QwenSuggestionGateway,
QwenUpstreamError,
SuggestionGateway,
ProgressGateway,
)
app = FastAPI(title="AIOA AI Service", version="0.1.0")
def get_gateway() -> SuggestionGateway:
return QwenSuggestionGateway(get_settings())
def get_progress_gateway() -> ProgressGateway:
return QwenSuggestionGateway(get_settings())
@app.get("/health")
async def health() -> dict[str, str]:
return {"status": "UP"}
@app.post("/v1/leave-drafts/suggest", response_model=LeaveDraftSuggestionResponse)
async def suggest_leave_draft(
request: LeaveDraftSuggestionRequest,
gateway: SuggestionGateway = Depends(get_gateway),
) -> LeaveDraftSuggestionResponse:
try:
suggestion = await gateway.suggest(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveDraftSuggestionResponse(
suggestion=suggestion,
model=get_settings().qwen_model,
)
@app.post("/v1/leave-progress/answer", response_model=LeaveProgressAnswerResponse)
async def answer_leave_progress(
request: LeaveProgressAnswerRequest,
gateway: ProgressGateway = Depends(get_progress_gateway),
) -> LeaveProgressAnswerResponse:
try:
answer = await gateway.answer_progress(request)
except QwenConfigurationError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
except QwenUpstreamError as exc:
raise HTTPException(status_code=502, detail=str(exc)) from exc
return LeaveProgressAnswerResponse(answer=answer, model=get_settings().qwen_model)
+67
View File
@@ -0,0 +1,67 @@
from datetime import datetime
from enum import StrEnum
from pydantic import BaseModel, ConfigDict, Field, model_validator
class LeaveType(StrEnum):
PERSONAL = "PERSONAL"
SICK = "SICK"
ANNUAL = "ANNUAL"
class LeaveDraftSuggestionRequest(BaseModel):
text: str = Field(min_length=1, max_length=2000)
timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=64)
now: datetime | None = None
class LeaveDraftSuggestion(BaseModel):
type: LeaveType | None = None
startsAt: datetime | None = None
endsAt: datetime | None = None
reason: str | None = Field(default=None, max_length=2000)
assumptions: list[str] = Field(default_factory=list, max_length=10)
needsClarification: list[str] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def validate_time_range(self) -> "LeaveDraftSuggestion":
if self.startsAt is not None and self.endsAt is not None:
if self.startsAt.tzinfo is None or self.endsAt.tzinfo is None:
raise ValueError("startsAt and endsAt must include timezone offsets")
if self.endsAt <= self.startsAt:
raise ValueError("endsAt must be later than startsAt")
if self.reason is not None:
normalized = self.reason.strip()
self.reason = normalized or None
return self
class LeaveDraftSuggestionResponse(BaseModel):
suggestion: LeaveDraftSuggestion
model: str
requiresUserConfirmation: bool = True
class LeaveProgressContext(BaseModel):
model_config = ConfigDict(extra="forbid")
requestId: str
type: LeaveType
status: str
startsAt: datetime
endsAt: datetime
activeTaskNames: list[str] = Field(default_factory=list, max_length=20)
completedTaskNames: list[str] = Field(default_factory=list, max_length=50)
processEnded: bool
timelineEventTypes: list[str] = Field(default_factory=list, max_length=50)
class LeaveProgressAnswerRequest(BaseModel):
question: str = Field(min_length=1, max_length=2000)
timezone: str = Field(min_length=1, max_length=64)
context: LeaveProgressContext
class LeaveProgressAnswerResponse(BaseModel):
answer: str = Field(min_length=1, max_length=2000)
model: str
+106
View File
@@ -0,0 +1,106 @@
import json
from datetime import datetime, timezone
from typing import Protocol
import httpx
from app.config import Settings
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveProgressAnswerRequest
class SuggestionGateway(Protocol):
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion: ...
class ProgressGateway(Protocol):
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str: ...
class QwenSuggestionGateway:
def __init__(self, settings: Settings) -> None:
self.settings = settings
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
reference_time = request.now or datetime.now(timezone.utc)
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{
"role": "user",
"content": json.dumps(
{
"text": request.text,
"timezone": request.timezone,
"referenceTime": reference_time.isoformat(),
},
ensure_ascii=False,
),
},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(
f"{self.settings.qwen_base_url}/chat/completions",
headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"},
json=payload,
)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
return LeaveDraftSuggestion.model_validate_json(content)
except (KeyError, IndexError, TypeError, ValueError) as exc:
raise QwenUpstreamError("Qwen returned an invalid structured response") from exc
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
if not self.settings.qwen_api_key:
raise QwenConfigurationError("QWEN_API_KEY is not configured")
payload = {
"model": self.settings.qwen_model,
"temperature": 0.1,
"response_format": {"type": "json_object"},
"messages": [
{"role": "system", "content": PROGRESS_SYSTEM_PROMPT},
{"role": "user", "content": request.model_dump_json()},
],
}
async with httpx.AsyncClient(timeout=self.settings.request_timeout_seconds) as client:
response = await client.post(f"{self.settings.qwen_base_url}/chat/completions", headers={"Authorization": f"Bearer {self.settings.qwen_api_key}"}, json=payload)
if response.status_code >= 400:
raise QwenUpstreamError(f"Qwen returned HTTP {response.status_code}")
try:
content = response.json()["choices"][0]["message"]["content"]
answer = json.loads(content)["answer"]
if not isinstance(answer, str) or not answer.strip() or len(answer) > 2000:
raise ValueError("invalid answer")
return answer.strip()
except (KeyError, IndexError, TypeError, ValueError, json.JSONDecodeError) as exc:
raise QwenUpstreamError("Qwen returned an invalid progress answer") from exc
SYSTEM_PROMPT = """
你是企业 OA 请假表单解析器。只把用户自然语言转换为 JSON 建议值,不执行任何业务动作。
输出必须是一个 JSON 对象,只允许字段:type、startsAt、endsAt、reason、assumptions、needsClarification。
type 只能是 PERSONAL、SICK、ANNUAL 或 null。时间必须是带时区偏移的 ISO-8601。
不能确定的值输出 null,并把需要用户补充的问题写入 needsClarification。
不得输出申请人、审批人、租户、权限、流程或隐藏字段。不得使用 Markdown。
""".strip()
PROGRESS_SYSTEM_PROMPT = """
你是企业 OA 只读流程进度助手。数据库记录已经由业务后端鉴权并选定,你只能依据输入 context 回答 question。
输出必须是 JSON 对象且只包含 answer 字段。回答必须明确当前 status;有 activeTaskNames 时说明当前节点;流程结束时说明已结束。
不得猜测审批人、原因、流程变量或预计完成时间,不得给出批准、驳回、撤回、提交等写操作指令,不得使用 Markdown。
""".strip()
class QwenConfigurationError(RuntimeError):
pass
class QwenUpstreamError(RuntimeError):
pass
+19
View File
@@ -0,0 +1,19 @@
[project]
name = "aioa-ai-service"
version = "0.1.0"
requires-python = ">=3.11"
dependencies = [
"fastapi==0.116.1",
"httpx==0.28.1",
"pydantic-settings==2.10.1",
"uvicorn[standard]==0.35.0",
]
[project.optional-dependencies]
dev = [
"pytest==8.4.1",
]
[tool.pytest.ini_options]
pythonpath = ["."]
testpaths = ["tests"]
+57
View File
@@ -0,0 +1,57 @@
from fastapi.testclient import TestClient
from app.main import app, get_progress_gateway
from app.models import LeaveProgressAnswerRequest
class FakeProgressGateway:
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
assert request.context.requestId == "leave-1"
assert request.context.activeTaskNames == ["主管审批"]
return "当前状态为审批中,正在等待主管审批。"
def test_answers_only_from_authorized_structured_context() -> None:
app.dependency_overrides[get_progress_gateway] = lambda: FakeProgressGateway()
try:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "我的请假到哪一步了?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1",
"type": "ANNUAL",
"status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z",
"endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": ["主管审批"],
"completedTaskNames": [],
"processEnded": False,
"timelineEventTypes": ["LEAVE_REQUEST_SUBMITTED"],
},
},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
assert response.json() == {"answer": "当前状态为审批中,正在等待主管审批。", "model": "qwen-plus"}
assert "processVariables" not in response.text
def test_rejects_unknown_context_fields() -> None:
response = TestClient(app).post(
"/v1/leave-progress/answer",
json={
"question": "进度?",
"timezone": "Asia/Shanghai",
"context": {
"requestId": "leave-1", "type": "ANNUAL", "status": "PENDING",
"startsAt": "2026-07-20T01:00:00Z", "endsAt": "2026-07-20T09:00:00Z",
"activeTaskNames": [], "completedTaskNames": [], "processEnded": False,
"timelineEventTypes": [], "processVariables": {"approverId": "secret"},
},
},
)
assert response.status_code == 422
+46
View File
@@ -0,0 +1,46 @@
from datetime import datetime
from fastapi.testclient import TestClient
from app.main import app, get_gateway
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveType
class FakeGateway:
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
assert request.text == "明天下午请事假四小时"
return LeaveDraftSuggestion(
type=LeaveType.PERSONAL,
startsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
reason="办理个人事务",
assumptions=["下午按 13:30 开始计算"],
)
def test_returns_structured_suggestion_without_executing_business_action() -> None:
app.dependency_overrides[get_gateway] = lambda: FakeGateway()
try:
response = TestClient(app).post(
"/v1/leave-drafts/suggest",
json={"text": "明天下午请事假四小时", "timezone": "Asia/Shanghai"},
)
finally:
app.dependency_overrides.clear()
assert response.status_code == 200
body = response.json()
assert body["suggestion"]["type"] == "PERSONAL"
assert body["requiresUserConfirmation"] is True
assert "applicantId" not in body["suggestion"]
def test_rejects_invalid_time_range_from_model() -> None:
try:
LeaveDraftSuggestion(
startsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
endsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
)
assert False, "validation should fail"
except ValueError:
pass
+2
View File
@@ -28,6 +28,8 @@ dependencies {
implementation("org.flywaydb:flyway-database-postgresql")
implementation("org.jetbrains.kotlin:kotlin-reflect")
implementation("org.flowable:flowable-spring-boot-starter-process:7.2.0")
implementation("io.minio:minio:8.5.17")
implementation("com.google.firebase:firebase-admin:9.4.3")
runtimeOnly("org.postgresql:postgresql")
testImplementation("org.springframework.boot:spring-boot-starter-test")
@@ -2,8 +2,10 @@ package com.all8ai.aioa
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.scheduling.annotation.EnableScheduling
@SpringBootApplication
@EnableScheduling
class AioaApplication
fun main(args: Array<String>) {
@@ -0,0 +1,36 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiLeaveDraftService
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/ai/leave-draft-suggestions")
class AiLeaveDraftController(
private val currentUserService: CurrentUserService,
private val service: AiLeaveDraftService,
) {
@PostMapping
fun suggest(
@AuthenticationPrincipal jwt: Jwt,
@Valid @RequestBody request: AiLeaveDraftSuggestionRequest,
): SuggestedLeaveDraft = service.suggest(
currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")),
request.text,
request.timezone,
)
}
data class AiLeaveDraftSuggestionRequest(
@field:NotBlank @field:Size(max = 2000) val text: String,
@field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai",
)
@@ -0,0 +1,20 @@
package com.all8ai.aioa.ai.api
import com.all8ai.aioa.ai.application.AiLeaveProgressService
import com.all8ai.aioa.ai.domain.LeaveProgressAnswerResult
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/ai/leave-progress-answers")
class AiLeaveProgressController(private val currentUserService: CurrentUserService, private val service: AiLeaveProgressService) {
@PostMapping fun answer(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: AiLeaveProgressRequest): LeaveProgressAnswerResult =
service.answer(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.text, request.timezone, request.selectedRequestId)
}
data class AiLeaveProgressRequest(@field:NotBlank @field:Size(max = 2000) val text: String, @field:NotBlank @field:Size(max = 64) val timezone: String = "Asia/Shanghai", val selectedRequestId: UUID? = null)
@@ -0,0 +1,45 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiLeaveDraftService(
private val gateway: AiLeaveDraftGateway,
private val auditService: AuditService,
) {
fun suggest(actor: CurrentUser, text: String, timezone: String): SuggestedLeaveDraft {
actor.requirePermission(ToolPermission.AI_LEAVE_DRAFT_SUGGEST)
val normalized = text.trim()
if (normalized.isEmpty() || normalized.length > 2000) {
throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "描述长度必须为 1 到 2000 个字符")
}
if (timezone.isBlank() || timezone.length > 64) {
throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效")
}
val result = gateway.suggest(normalized, timezone)
if (!result.requiresUserConfirmation) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_CONFIRMATION_REQUIRED", "AI 建议必须要求用户确认")
}
auditService.recordSuccess(
actor,
"AI_LEAVE_DRAFT_SUGGESTED",
"AI_SUGGESTION",
"leave-draft",
null,
mapOf(
"model" to result.model,
"promptLength" to normalized.length,
"clarificationCount" to result.suggestion.needsClarification.size,
),
)
return result
}
}
@@ -0,0 +1,55 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.*
import com.all8ai.aioa.approval.domain.LeaveRequest
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.LocalDate
import java.time.ZoneId
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class AiLeaveProgressService(
private val repository: LeaveRequestRepository,
private val workflow: LeaveWorkflowGateway,
private val gateway: AiLeaveProgressGateway,
private val auditService: AuditService,
) {
fun answer(actor: CurrentUser, text: String, timezone: String, selectedRequestId: UUID?): LeaveProgressAnswerResult {
actor.requirePermission(ToolPermission.AI_LEAVE_PROGRESS_READ_OWN)
val question = text.trim()
if (question.isEmpty() || question.length > 2000) throw ApiException(HttpStatus.BAD_REQUEST, "AI_PROMPT_INVALID", "问题长度必须为 1 到 2000 个字符")
val zone = try { ZoneId.of(timezone) } catch (_: Exception) { throw ApiException(HttpStatus.BAD_REQUEST, "TIMEZONE_INVALID", "时区无效") }
val own = repository.listOwn(actor.tenantId, actor.id, 100)
val matches = selectedRequestId?.let { id -> own.filter { it.id == id } } ?: filter(question, zone, own)
if (selectedRequestId != null && matches.isEmpty()) throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "未找到本人的请假申请")
if (matches.size != 1) return LeaveProgressAnswerResult(true, matches.take(10).map(::candidate))
val request = matches.single()
val rawProgress = request.processInstanceId?.let(workflow::getProgress)
val progress = LeaveProgressView(rawProgress?.activeTaskNames.orEmpty(), rawProgress?.completedTaskNames.orEmpty(), rawProgress?.processEnded ?: request.status.name !in setOf("DRAFT", "PENDING"))
val context = LeaveProgressContext(request.id, request.type.name, request.status.name, request.startsAt, request.endsAt, progress.activeTaskNames, progress.completedTaskNames, progress.processEnded, repository.listTimeline(actor.tenantId, request.id).map { it.eventType })
val generated = gateway.answer(question, timezone, context)
auditService.recordSuccess(actor, "AI_LEAVE_PROGRESS_QUERIED", "LEAVE_REQUEST", request.id.toString(), null, mapOf("model" to generated.model, "promptLength" to question.length, "requestId" to request.id.toString()))
return LeaveProgressAnswerResult(false, answer = generated.answer, request = candidate(request), progress = progress)
}
private fun filter(text: String, zone: ZoneId, requests: List<LeaveRequest>): List<LeaveRequest> {
var result = requests
val type = when { "病假" in text -> "SICK"; "年假" in text -> "ANNUAL"; "事假" in text -> "PERSONAL"; else -> null }
if (type != null) result = result.filter { it.type.name == type }
val today = LocalDate.now(zone)
if ("昨天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today.minusDays(1) }
if ("今天" in text) result = result.filter { it.createdAt.atZone(zone).toLocalDate() == today }
if (listOf("最近", "最新", "刚才", "刚提交").any { it in text } && result.isNotEmpty()) return listOf(result.maxBy { it.createdAt })
return result
}
private fun candidate(it: LeaveRequest) = LeaveProgressCandidate(it.id, it.type.name, it.status.name, it.startsAt, it.endsAt, it.createdAt)
}
@@ -0,0 +1,22 @@
package com.all8ai.aioa.ai.domain
import java.time.Instant
data class LeaveDraftSuggestion(
val type: String?,
val startsAt: Instant?,
val endsAt: Instant?,
val reason: String?,
val assumptions: List<String>,
val needsClarification: List<String>,
)
data class SuggestedLeaveDraft(
val suggestion: LeaveDraftSuggestion,
val model: String,
val requiresUserConfirmation: Boolean,
)
fun interface AiLeaveDraftGateway {
fun suggest(text: String, timezone: String): SuggestedLeaveDraft
}
@@ -0,0 +1,45 @@
package com.all8ai.aioa.ai.domain
import java.time.Instant
import java.util.UUID
data class LeaveProgressContext(
val requestId: UUID,
val type: String,
val status: String,
val startsAt: Instant,
val endsAt: Instant,
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
val timelineEventTypes: List<String>,
)
data class GeneratedProgressAnswer(val answer: String, val model: String)
fun interface AiLeaveProgressGateway {
fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer
}
data class LeaveProgressCandidate(
val id: UUID,
val type: String,
val status: String,
val startsAt: Instant,
val endsAt: Instant,
val createdAt: Instant,
)
data class LeaveProgressAnswerResult(
val requiresSelection: Boolean,
val candidates: List<LeaveProgressCandidate> = emptyList(),
val answer: String? = null,
val request: LeaveProgressCandidate? = null,
val progress: LeaveProgressView? = null,
)
data class LeaveProgressView(
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
)
@@ -0,0 +1,32 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiLeaveDraftGateway(
@Value("\${aioa.ai-service.url}") aiServiceUrl: String,
) : AiLeaveDraftGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun suggest(text: String, timezone: String): SuggestedLeaveDraft = try {
client.post()
.uri("/v1/leave-drafts/suggest")
.body(SuggestionRequest(text, timezone))
.retrieve()
.body(SuggestedLeaveDraft::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回建议")
} catch (exception: ApiException) {
throw exception
} catch (exception: RestClientException) {
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
}
}
private data class SuggestionRequest(val text: String, val timezone: String)
@@ -0,0 +1,19 @@
package com.all8ai.aioa.ai.infrastructure
import com.all8ai.aioa.ai.domain.*
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.beans.factory.annotation.Value
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Component
import org.springframework.web.client.RestClient
import org.springframework.web.client.RestClientException
@Component
class HttpAiLeaveProgressGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiLeaveProgressGateway {
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer = try {
client.post().uri("/v1/leave-progress/answer").body(ProgressRequest(question, timezone, context)).retrieve().body(GeneratedProgressAnswer::class.java)
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回回答")
} catch (e: ApiException) { throw e } catch (_: RestClientException) { throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用") }
}
private data class ProgressRequest(val question: String, val timezone: String, val context: LeaveProgressContext)
@@ -9,26 +9,32 @@ import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.web.ApiException
import com.all8ai.aioa.shared.web.TraceIdFilter
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.notification.application.NotificationService
import org.slf4j.MDC
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Transactional
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class ApprovalTaskService(
private val workflowGateway: LeaveWorkflowGateway,
private val leaveRequestRepository: LeaveRequestRepository,
private val auditService: AuditService,
private val notificationService: NotificationService? = null,
) {
fun listAssigned(actor: CurrentUser): List<ApprovalTask> =
workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
fun listAssigned(actor: CurrentUser): List<ApprovalTask> {
actor.requirePermission(ToolPermission.APPROVAL_TASK_READ_ASSIGNED)
return workflowGateway.listAssignedTasks(actor.id).mapNotNull { task ->
val request = leaveRequestRepository.findById(actor.tenantId, task.leaveRequestId)
?: return@mapNotNull null
if (request.status != LeaveStatus.PENDING) return@mapNotNull null
ApprovalTask(task.id, task.name, task.createdAt, request)
}
}
@Transactional
fun approve(
@@ -56,6 +62,7 @@ class ApprovalTaskService(
comment: String?,
approved: Boolean,
): LeaveRequest {
actor.requirePermission(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
validate(idempotencyKey, expectedVersion, comment)
val task = workflowGateway.resolveTask(taskId)
?: throw ApiException(HttpStatus.NOT_FOUND, "APPROVAL_TASK_NOT_FOUND", "审批任务不存在")
@@ -131,6 +138,17 @@ class ApprovalTaskService(
"comment" to comment?.trim(),
),
)
if (completion.processEnded) {
notificationService?.notify(
actor.tenantId,
request.applicantId,
if (approved) "LEAVE_APPROVED" else "LEAVE_REJECTED",
if (approved) "请假申请已通过" else "请假申请已驳回",
if (approved) "你的请假申请已完成审批。" else "你的请假申请未通过审批,请查看审批意见。",
"LEAVE_REQUEST",
request.id.toString(),
)
}
return result
}
@@ -17,11 +17,14 @@ import org.springframework.transaction.annotation.Transactional
import org.slf4j.MDC
import com.all8ai.aioa.shared.web.TraceIdFilter
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.notification.application.NotificationService
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.Instant
import java.time.Duration
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class LeaveRequestService(
@@ -29,8 +32,10 @@ class LeaveRequestService(
private val auditService: AuditService,
private val routingRepository: ApprovalRoutingRepository,
private val workflowGateway: LeaveWorkflowGateway,
private val notificationService: NotificationService? = null,
) {
fun createDraft(actor: CurrentUser, idempotencyKey: String, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
validateIdempotencyKey(idempotencyKey)
val content = command.toValidatedContent()
val fingerprint = fingerprint(content)
@@ -53,6 +58,7 @@ class LeaveRequestService(
}
fun updateDraft(actor: CurrentUser, id: UUID, command: SaveDraftCommand): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val content = command.toValidatedContent()
repository.updateDraft(actor.tenantId, actor.id, id, command.version, content)?.let { return it }
@@ -64,14 +70,20 @@ class LeaveRequestService(
throw ApiException(HttpStatus.CONFLICT, "VERSION_CONFLICT", "申请已被其他操作更新,请刷新后重试")
}
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest =
repository.findOwn(actor.tenantId, actor.id, id)
fun getOwn(actor: CurrentUser, id: UUID): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
return repository.findOwn(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
}
fun listOwn(actor: CurrentUser): List<LeaveRequest> = repository.listOwn(actor.tenantId, actor.id, 100)
fun listOwn(actor: CurrentUser): List<LeaveRequest> {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN)
return repository.listOwn(actor.tenantId, actor.id, 100)
}
@Transactional
fun submit(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val existing = getOwn(actor, id)
val durationMinutes = Duration.between(existing.startsAt, existing.endsAt).toMinutes()
val approverId = routingRepository.findDepartmentManager(actor.tenantId, actor.id)
@@ -125,6 +137,15 @@ class LeaveRequestService(
process.processInstanceId,
process.processDefinitionId,
)
notificationService?.notify(
actor.tenantId,
approverId,
"APPROVAL_TASK_ASSIGNED",
"新的请假审批待办",
"${actor.displayName} 提交了请假申请,请及时处理。",
"LEAVE_REQUEST",
outcome.leaveRequest.id.toString(),
)
return outcome.leaveRequest.copy(
processInstanceId = process.processInstanceId,
processDefinitionId = process.processDefinitionId,
@@ -133,6 +154,7 @@ class LeaveRequestService(
@Transactional
fun withdraw(actor: CurrentUser, id: UUID, idempotencyKey: String, expectedVersion: Long): LeaveRequest {
actor.requirePermission(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
val outcome = transition(
actor = actor,
id = id,
@@ -0,0 +1,93 @@
package com.all8ai.aioa.attachment.api
import com.all8ai.aioa.attachment.application.CreateUploadCommand
import com.all8ai.aioa.attachment.application.LeaveAttachmentService
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Positive
import jakarta.validation.constraints.Size
import org.springframework.http.HttpStatus
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.DeleteMapping
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestBody
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.ResponseStatus
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/leave-requests/{leaveRequestId}/attachments")
class LeaveAttachmentController(
private val currentUserService: CurrentUserService,
private val service: LeaveAttachmentService,
) {
@PostMapping("/upload-tasks")
@ResponseStatus(HttpStatus.CREATED)
fun createUpload(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@Valid @RequestBody request: CreateAttachmentUploadRequest,
): AttachmentUploadResponse {
val upload = service.createUpload(currentUser(jwt), leaveRequestId, request.toCommand())
return AttachmentUploadResponse(upload.attachment.toResponse(), upload.uploadUrl)
}
@PostMapping("/{attachmentId}/complete")
fun complete(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = service.complete(currentUser(jwt), leaveRequestId, attachmentId).toResponse()
@GetMapping
fun list(@AuthenticationPrincipal jwt: Jwt, @PathVariable leaveRequestId: UUID) =
service.list(currentUser(jwt), leaveRequestId).map(LeaveAttachment::toResponse)
@GetMapping("/{attachmentId}/download")
fun download(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = AttachmentDownloadResponse(service.download(currentUser(jwt), leaveRequestId, attachmentId))
@DeleteMapping("/{attachmentId}")
@ResponseStatus(HttpStatus.NO_CONTENT)
fun delete(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable leaveRequestId: UUID,
@PathVariable attachmentId: UUID,
) = service.delete(currentUser(jwt), leaveRequestId, attachmentId)
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
}
data class CreateAttachmentUploadRequest(
@field:NotBlank @field:Size(max = 255) val fileName: String,
@field:NotBlank @field:Size(max = 128) val contentType: String,
@field:Positive val sizeBytes: Long,
) {
fun toCommand() = CreateUploadCommand(fileName, contentType, sizeBytes)
}
data class AttachmentUploadResponse(val attachment: LeaveAttachmentResponse, val uploadUrl: String)
data class AttachmentDownloadResponse(val downloadUrl: String)
data class LeaveAttachmentResponse(
val id: UUID,
val fileName: String,
val contentType: String,
val sizeBytes: Long,
val status: String,
val createdAt: Instant,
val completedAt: Instant?,
)
private fun LeaveAttachment.toResponse() = LeaveAttachmentResponse(
id, fileName, contentType, sizeBytes, status.name, createdAt, completedAt,
)
@@ -0,0 +1,113 @@
package com.all8ai.aioa.attachment.application
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.attachment.domain.AttachmentStatus
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
@Service
class LeaveAttachmentService(
private val leaveRequests: LeaveRequestRepository,
private val attachments: LeaveAttachmentRepository,
private val storage: ObjectStorageGateway,
private val auditService: AuditService,
) {
fun createUpload(actor: CurrentUser, leaveRequestId: UUID, command: CreateUploadCommand): AttachmentUpload {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val fileName = command.fileName.trim().takeIf { it.isNotEmpty() && it.length <= 255 }
?: invalid("ATTACHMENT_NAME_INVALID", "附件名称无效")
if (command.sizeBytes !in 1..MAX_SIZE_BYTES) invalid("ATTACHMENT_SIZE_INVALID", "附件不能超过 10 MB")
if (command.contentType !in ALLOWED_CONTENT_TYPES) invalid("ATTACHMENT_TYPE_INVALID", "不支持该附件类型")
val id = UuidV7.generate()
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_")
val objectKey = "${actor.tenantId}/leave/$leaveRequestId/$id-$safeName"
val attachment = attachments.create(
LeaveAttachment(id, actor.tenantId, leaveRequestId, actor.id, fileName, command.contentType,
command.sizeBytes, objectKey, AttachmentStatus.PENDING, Instant.now(), null),
)
val uploadUrl = storage.createUploadUrl(objectKey)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_UPLOAD_CREATE", "LEAVE_ATTACHMENT", id.toString(), null,
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to command.sizeBytes, "contentType" to command.contentType))
return AttachmentUpload(attachment, uploadUrl)
}
fun complete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
if (attachment.status == AttachmentStatus.READY) return attachment
val stored = storage.stat(attachment.objectKey)
if (stored.sizeBytes != attachment.sizeBytes) invalid("ATTACHMENT_SIZE_MISMATCH", "附件大小校验失败")
if (stored.contentType != null && stored.contentType != attachment.contentType) {
invalid("ATTACHMENT_TYPE_MISMATCH", "附件类型校验失败")
}
val completed = attachments.markReady(actor.tenantId, attachmentId)
?: throw ApiException(HttpStatus.CONFLICT, "ATTACHMENT_STATE_CONFLICT", "附件状态已变化")
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_COMPLETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to completed.sizeBytes))
return completed
}
fun list(actor: CurrentUser, leaveRequestId: UUID): List<LeaveAttachment> {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwn(actor, leaveRequestId)
return attachments.list(actor.tenantId, leaveRequestId)
}
fun download(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): String {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwn(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
if (attachment.status != AttachmentStatus.READY) invalid("ATTACHMENT_NOT_READY", "附件尚未上传完成")
val downloadUrl = storage.createDownloadUrl(attachment.objectKey)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DOWNLOAD", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId))
return downloadUrl
}
fun delete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID) {
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
requireOwnDraft(actor, leaveRequestId)
val attachment = find(actor, leaveRequestId, attachmentId)
storage.delete(attachment.objectKey)
attachments.delete(actor.tenantId, attachmentId)
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DELETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
mapOf("leaveRequestId" to leaveRequestId))
}
private fun find(actor: CurrentUser, requestId: UUID, attachmentId: UUID) =
attachments.find(actor.tenantId, requestId, attachmentId)
?: throw ApiException(HttpStatus.NOT_FOUND, "ATTACHMENT_NOT_FOUND", "附件不存在")
private fun requireOwn(actor: CurrentUser, id: UUID) = leaveRequests.findOwn(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
private fun requireOwnDraft(actor: CurrentUser, id: UUID) {
if (requireOwn(actor, id).status != LeaveStatus.DRAFT) {
throw ApiException(HttpStatus.CONFLICT, "LEAVE_REQUEST_NOT_DRAFT", "只有草稿可以修改附件")
}
}
private fun invalid(code: String, message: String): Nothing = throw ApiException(HttpStatus.BAD_REQUEST, code, message)
companion object {
const val MAX_SIZE_BYTES = 10L * 1024 * 1024
val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "application/pdf")
}
}
data class CreateUploadCommand(val fileName: String, val contentType: String, val sizeBytes: Long)
data class AttachmentUpload(val attachment: LeaveAttachment, val uploadUrl: String)
@@ -0,0 +1,37 @@
package com.all8ai.aioa.attachment.domain
import java.time.Instant
import java.util.UUID
enum class AttachmentStatus { PENDING, READY }
data class LeaveAttachment(
val id: UUID,
val tenantId: UUID,
val leaveRequestId: UUID,
val uploaderId: UUID,
val fileName: String,
val contentType: String,
val sizeBytes: Long,
val objectKey: String,
val status: AttachmentStatus,
val createdAt: Instant,
val completedAt: Instant?,
)
interface LeaveAttachmentRepository {
fun create(attachment: LeaveAttachment): LeaveAttachment
fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment?
fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment>
fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment?
fun delete(tenantId: UUID, attachmentId: UUID): Boolean
}
interface ObjectStorageGateway {
fun createUploadUrl(objectKey: String): String
fun stat(objectKey: String): StoredObject
fun createDownloadUrl(objectKey: String): String
fun delete(objectKey: String)
}
data class StoredObject(val sizeBytes: Long, val contentType: String?)
@@ -0,0 +1,66 @@
package com.all8ai.aioa.attachment.infrastructure
import com.all8ai.aioa.attachment.domain.AttachmentStatus
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqLeaveAttachmentRepository(private val dsl: DSLContext) : LeaveAttachmentRepository {
override fun create(attachment: LeaveAttachment): LeaveAttachment {
dsl.execute(
"""
INSERT INTO business.leave_attachment (
id, tenant_id, leave_request_id, uploader_id, file_name,
content_type, size_bytes, object_key, status
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
attachment.id, attachment.tenantId, attachment.leaveRequestId, attachment.uploaderId,
attachment.fileName, attachment.contentType, attachment.sizeBytes, attachment.objectKey,
attachment.status.name,
)
return find(attachment.tenantId, attachment.leaveRequestId, attachment.id)!!
}
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment? =
dsl.fetchOne(
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? AND id = ?",
tenantId, leaveRequestId, attachmentId,
)?.let(::map)
override fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment> = dsl.fetch(
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? ORDER BY created_at, id",
tenantId, leaveRequestId,
).map(::map)
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = dsl.fetchOne(
"""
UPDATE business.leave_attachment
SET status = 'READY', completed_at = CURRENT_TIMESTAMP
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
RETURNING *
""".trimIndent(),
tenantId, attachmentId,
)?.let(::map)
override fun delete(tenantId: UUID, attachmentId: UUID): Boolean =
dsl.execute("DELETE FROM business.leave_attachment WHERE tenant_id = ? AND id = ?", tenantId, attachmentId) == 1
private fun map(record: Record) = LeaveAttachment(
id = record.get("id", UUID::class.java)!!,
tenantId = record.get("tenant_id", UUID::class.java)!!,
leaveRequestId = record.get("leave_request_id", UUID::class.java)!!,
uploaderId = record.get("uploader_id", UUID::class.java)!!,
fileName = record.get("file_name", String::class.java)!!,
contentType = record.get("content_type", String::class.java)!!,
sizeBytes = record.get("size_bytes", Long::class.java)!!,
objectKey = record.get("object_key", String::class.java)!!,
status = AttachmentStatus.valueOf(record.get("status", String::class.java)!!),
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
completedAt = record.get("completed_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,51 @@
package com.all8ai.aioa.attachment.infrastructure
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
import com.all8ai.aioa.attachment.domain.StoredObject
import io.minio.BucketExistsArgs
import io.minio.GetPresignedObjectUrlArgs
import io.minio.MakeBucketArgs
import io.minio.MinioClient
import io.minio.RemoveObjectArgs
import io.minio.StatObjectArgs
import io.minio.http.Method
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.util.concurrent.TimeUnit
@Component
class MinioObjectStorageGateway(
@Value("\${aioa.object-storage.endpoint}") endpoint: String,
@Value("\${aioa.object-storage.access-key}") accessKey: String,
@Value("\${aioa.object-storage.secret-key}") secretKey: String,
@Value("\${aioa.object-storage.bucket}") private val bucket: String,
) : ObjectStorageGateway {
private val client = MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build()
override fun createUploadUrl(objectKey: String): String {
ensureBucket()
return client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).`object`(objectKey)
.expiry(15, TimeUnit.MINUTES).build(),
)
}
override fun stat(objectKey: String): StoredObject = client.statObject(
StatObjectArgs.builder().bucket(bucket).`object`(objectKey).build(),
).let { StoredObject(it.size(), it.contentType()) }
override fun createDownloadUrl(objectKey: String): String = client.getPresignedObjectUrl(
GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucket).`object`(objectKey)
.expiry(5, TimeUnit.MINUTES).build(),
)
override fun delete(objectKey: String) {
client.removeObject(RemoveObjectArgs.builder().bucket(bucket).`object`(objectKey).build())
}
private fun ensureBucket() {
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build())
}
}
}
@@ -0,0 +1,49 @@
package com.all8ai.aioa.device.api
import com.all8ai.aioa.device.application.UserDeviceService
import com.all8ai.aioa.device.domain.DevicePlatform
import com.all8ai.aioa.device.domain.UserDevice
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.validation.Valid
import jakarta.validation.constraints.NotBlank
import jakarta.validation.constraints.Size
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.*
import java.util.UUID
@RestController
@RequestMapping("/api/v1/devices")
class UserDeviceController(private val currentUserService: CurrentUserService, private val service: UserDeviceService) {
@PostMapping("/register")
fun register(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: RegisterDeviceRequest): UserDevice =
service.register(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.id, request.name, request.platform, request.appVersion)
@GetMapping fun list(@AuthenticationPrincipal jwt: Jwt): List<UserDevice> =
service.list(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
@DeleteMapping("/{id}") fun revoke(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID): UserDevice =
service.revoke(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
@PutMapping("/{id}/push-token")
fun updatePushToken(
@AuthenticationPrincipal jwt: Jwt,
@PathVariable id: UUID,
@RequestHeader("X-AIOA-Device-Id") currentDeviceId: UUID,
@Valid @RequestBody request: PushTokenRequest,
) {
if (id != currentDeviceId) throw com.all8ai.aioa.shared.web.ApiException(
org.springframework.http.HttpStatus.FORBIDDEN, "DEVICE_MISMATCH", "只能更新当前设备的推送令牌",
)
service.updatePushToken(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.token)
}
}
data class RegisterDeviceRequest(
val id: UUID,
@field:NotBlank @field:Size(max = 200) val name: String,
val platform: DevicePlatform,
@field:Size(max = 64) val appVersion: String? = null,
)
data class PushTokenRequest(@field:Size(max = 4096) val token: String?)
@@ -0,0 +1,40 @@
package com.all8ai.aioa.device.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.device.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.util.UUID
import org.springframework.transaction.annotation.Transactional
@Service
class UserDeviceService(private val repository: UserDeviceRepository, private val auditService: AuditService) {
fun register(actor: CurrentUser, id: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice {
val normalizedName = name.trim().takeIf { it.isNotEmpty() && it.length <= 200 }
?: throw ApiException(HttpStatus.BAD_REQUEST, "DEVICE_NAME_INVALID", "设备名称无效")
val normalizedVersion = appVersion?.trim()?.takeIf { it.isNotEmpty() && it.length <= 64 }
return repository.register(id, actor.tenantId, actor.id, normalizedName, platform, normalizedVersion)
?: throw ApiException(HttpStatus.UNAUTHORIZED, "DEVICE_REVOKED", "该设备已被撤销,请联系管理员或使用其他设备")
}
fun list(actor: CurrentUser): List<UserDevice> = repository.list(actor.tenantId, actor.id)
fun revoke(actor: CurrentUser, id: UUID): UserDevice {
val device = repository.revoke(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在")
auditService.recordSuccess(actor, "USER_DEVICE_REVOKED", "USER_DEVICE", id.toString(), null, mapOf("platform" to device.platform.name))
return device
}
@Transactional
fun updatePushToken(actor: CurrentUser, id: UUID, token: String?) {
val normalized = token?.trim()?.takeIf { it.isNotEmpty() && it.length <= 4096 }
if (token != null && normalized == null) throw ApiException(HttpStatus.BAD_REQUEST, "PUSH_TOKEN_INVALID", "推送令牌无效")
normalized?.let(repository::clearPushToken)
if (!repository.updatePushToken(actor.tenantId, actor.id, id, normalized)) {
throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在或已撤销")
}
}
}
@@ -0,0 +1,30 @@
package com.all8ai.aioa.device.domain
import java.time.Instant
import java.util.UUID
enum class DeviceStatus { ACTIVE, REVOKED }
enum class DevicePlatform { IOS, ANDROID, OTHER }
data class UserDevice(
val id: UUID,
val tenantId: UUID,
val userId: UUID,
val name: String,
val platform: DevicePlatform,
val appVersion: String?,
val status: DeviceStatus,
val registeredAt: Instant,
val lastSeenAt: Instant,
val revokedAt: Instant?,
)
interface UserDeviceRepository {
fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice?
fun list(tenantId: UUID, userId: UUID): List<UserDevice>
fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice?
fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean
fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean
fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String>
fun clearPushToken(token: String)
}
@@ -0,0 +1,39 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.identity.application.CurrentUserService
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.http.MediaType
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.stereotype.Component
import org.springframework.web.servlet.HandlerInterceptor
import java.util.UUID
@Component
class DeviceSessionInterceptor(
private val currentUserService: CurrentUserService,
private val devices: UserDeviceRepository,
) : HandlerInterceptor {
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
if (request.method == "OPTIONS" || !request.requestURI.startsWith("/api/v1/") || request.requestURI == "/api/v1/devices/register") return true
val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt ?: return true
val actor = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val deviceId = request.getHeader(DEVICE_ID_HEADER)?.let { value ->
runCatching { UUID.fromString(value) }.getOrNull()
}
if (deviceId != null && devices.touchActive(actor.tenantId, actor.id, deviceId)) return true
response.status = HttpServletResponse.SC_UNAUTHORIZED
response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE
response.characterEncoding = Charsets.UTF_8.name()
response.setHeader(AUTH_ERROR_HEADER, "DEVICE_REVOKED")
response.writer.write("""{"title":"Unauthorized","status":401,"detail":"设备未注册或已被撤销","code":"DEVICE_REVOKED"}""")
return false
}
companion object {
const val DEVICE_ID_HEADER = "X-AIOA-Device-Id"
const val AUTH_ERROR_HEADER = "X-AIOA-Auth-Error"
}
}
@@ -0,0 +1,12 @@
package com.all8ai.aioa.device.infrastructure
import org.springframework.context.annotation.Configuration
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
@Configuration
class DeviceWebConfiguration(private val interceptor: DeviceSessionInterceptor) : WebMvcConfigurer {
override fun addInterceptors(registry: InterceptorRegistry) {
registry.addInterceptor(interceptor)
}
}
@@ -0,0 +1,68 @@
package com.all8ai.aioa.device.infrastructure
import com.all8ai.aioa.device.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqUserDeviceRepository(private val dsl: DSLContext) : UserDeviceRepository {
override fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice? =
dsl.fetchOne(
"""
INSERT INTO identity.user_device (id, tenant_id, user_id, name, platform, app_version)
VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT (tenant_id, user_id, id) DO UPDATE SET
name = EXCLUDED.name, platform = EXCLUDED.platform,
app_version = EXCLUDED.app_version, last_seen_at = CURRENT_TIMESTAMP
WHERE user_device.status = 'ACTIVE'
RETURNING *
""".trimIndent(), id, tenantId, userId, name, platform.name, appVersion,
)?.let(::map)
override fun list(tenantId: UUID, userId: UUID): List<UserDevice> = dsl.fetch(
"SELECT * FROM identity.user_device WHERE tenant_id = ? AND user_id = ? ORDER BY last_seen_at DESC",
tenantId, userId,
).map(::map)
override fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice? = dsl.fetchOne(
"""
UPDATE identity.user_device SET status = 'REVOKED', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND user_id = ? AND id = ? RETURNING *
""".trimIndent(), tenantId, userId, id,
)?.let(::map)
override fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean = dsl.execute(
"UPDATE identity.user_device SET last_seen_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
tenantId, userId, id,
) == 1
override fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean = dsl.execute(
"UPDATE identity.user_device SET push_token = ?, push_token_updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
token, tenantId, userId, id,
) == 1
override fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String> = dsl.fetch(
"SELECT push_token FROM identity.user_device WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE' AND push_token IS NOT NULL",
tenantId, userId,
).map { it.get("push_token", String::class.java)!! }
override fun clearPushToken(token: String) {
dsl.execute("UPDATE identity.user_device SET push_token = NULL, push_token_updated_at = CURRENT_TIMESTAMP WHERE push_token = ?", token)
}
private fun map(record: Record) = UserDevice(
record.get("id", UUID::class.java)!!,
record.get("tenant_id", UUID::class.java)!!,
record.get("user_id", UUID::class.java)!!,
record.get("name", String::class.java)!!,
DevicePlatform.valueOf(record.get("platform", String::class.java)!!),
record.get("app_version", String::class.java),
DeviceStatus.valueOf(record.get("status", String::class.java)!!),
record.get("registered_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("last_seen_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("revoked_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,76 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
@RestController
@RequestMapping("/api/v1/form-definitions")
class FormDefinitionController {
@GetMapping("/{formKey}")
fun getDefinition(@PathVariable formKey: String): FormDefinitionResponse = when (formKey) {
LEAVE_REQUEST_FORM_KEY -> leaveRequestDefinition
else -> throw ApiException(HttpStatus.NOT_FOUND, "FORM_DEFINITION_NOT_FOUND", "Form definition not found")
}
companion object {
const val LEAVE_REQUEST_FORM_KEY = "leave-request"
val leaveRequestDefinition = FormDefinitionResponse(
key = LEAVE_REQUEST_FORM_KEY,
version = 1,
dataSchema = mapOf(
"\$id" to "leave-request-v1",
"title" to "请假申请",
"type" to "object",
"required" to listOf("type", "startsAt", "endsAt", "reason"),
"properties" to mapOf(
"type" to mapOf("type" to "string", "enum" to listOf("PERSONAL", "SICK", "ANNUAL")),
"startsAt" to mapOf("type" to "string", "format" to "date-time"),
"endsAt" to mapOf("type" to "string", "format" to "date-time"),
"reason" to mapOf("type" to "string", "minLength" to 1, "maxLength" to 2000),
),
),
uiSchema = mapOf(
"description" to "表单卡片由服务端 Schema 自动生成,字段、顺序、控件和校验均可版本化。",
"sections" to listOf(
mapOf(
"title" to "请假信息",
"controls" to listOf(
mapOf(
"field" to "type",
"label" to "请假类型",
"control" to "select",
"optionLabels" to mapOf("PERSONAL" to "事假", "SICK" to "病假", "ANNUAL" to "年假"),
),
mapOf("field" to "startsAt", "label" to "开始时间", "control" to "dateTime"),
mapOf("field" to "endsAt", "label" to "结束时间", "control" to "dateTime"),
),
),
mapOf(
"title" to "补充说明",
"controls" to listOf(
mapOf(
"field" to "reason",
"label" to "请假原因",
"control" to "textArea",
"placeholder" to "请简要说明请假原因",
"helperText" to "AI 可以帮助整理表达,但提交前必须由你确认。",
),
),
),
),
),
)
}
}
data class FormDefinitionResponse(
val key: String,
val version: Int,
val dataSchema: Map<String, Any>,
val uiSchema: Map<String, Any>,
)
@@ -7,6 +7,9 @@ import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.util.UUID
import com.all8ai.aioa.shared.security.AuthorizationPolicy
import com.all8ai.aioa.shared.security.DataScope
import com.all8ai.aioa.shared.security.ToolPermission
@RestController
@RequestMapping("/api/v1/me")
@@ -16,6 +19,7 @@ class CurrentUserController(
@GetMapping
fun currentUser(@AuthenticationPrincipal jwt: Jwt): CurrentUserResponse {
val currentUser = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
val capabilities = AuthorizationPolicy.capabilities(currentUser)
return CurrentUserResponse(
id = currentUser.id,
tenantId = currentUser.tenantId,
@@ -25,6 +29,8 @@ class CurrentUserController(
department = currentUser.department?.let { OrganizationRef(it.id, it.name) },
position = currentUser.position?.let { OrganizationRef(it.id, it.name) },
roles = currentUser.roles,
permissions = capabilities.permissions,
dataScopes = capabilities.dataScopes,
)
}
}
@@ -38,6 +44,8 @@ data class CurrentUserResponse(
val department: OrganizationRef?,
val position: OrganizationRef?,
val roles: Set<String>,
val permissions: Set<ToolPermission>,
val dataScopes: Set<DataScope>,
)
data class OrganizationRef(
@@ -0,0 +1,49 @@
package com.all8ai.aioa.notification.api
import com.all8ai.aioa.identity.application.CurrentUserService
import com.all8ai.aioa.notification.application.NotificationService
import com.all8ai.aioa.notification.domain.Notification
import org.springframework.security.core.annotation.AuthenticationPrincipal
import org.springframework.security.oauth2.jwt.Jwt
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.PostMapping
import org.springframework.web.bind.annotation.RequestMapping
import org.springframework.web.bind.annotation.RestController
import java.time.Instant
import java.util.UUID
@RestController
@RequestMapping("/api/v1/notifications")
class NotificationController(
private val currentUserService: CurrentUserService,
private val service: NotificationService,
) {
@GetMapping
fun list(@AuthenticationPrincipal jwt: Jwt) = service.list(currentUser(jwt)).map(Notification::toResponse)
@GetMapping("/unread-count")
fun unreadCount(@AuthenticationPrincipal jwt: Jwt) = UnreadCountResponse(service.unreadCount(currentUser(jwt)))
@PostMapping("/{id}/read")
fun markRead(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID) =
service.markRead(currentUser(jwt), id).toResponse()
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
}
data class UnreadCountResponse(val unreadCount: Int)
data class NotificationResponse(
val id: UUID,
val type: String,
val title: String,
val body: String,
val resourceType: String?,
val resourceId: String?,
val createdAt: Instant,
val readAt: Instant?,
)
private fun Notification.toResponse() = NotificationResponse(
id, type, title, body, resourceType, resourceId, createdAt, readAt,
)
@@ -0,0 +1,59 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.notification.domain.Notification
import com.all8ai.aioa.notification.domain.NotificationRepository
import com.all8ai.aioa.shared.id.UuidV7
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import java.time.Instant
import java.util.UUID
import com.all8ai.aioa.shared.security.ToolPermission
import com.all8ai.aioa.shared.security.requirePermission
import com.all8ai.aioa.notification.domain.PushOutboxRepository
import org.springframework.transaction.annotation.Transactional
@Service
class NotificationService(
private val repository: NotificationRepository,
private val auditService: AuditService,
private val pushOutbox: PushOutboxRepository? = null,
) {
@Transactional
fun notify(
tenantId: UUID,
recipientId: UUID,
type: String,
title: String,
body: String,
resourceType: String? = null,
resourceId: String? = null,
): Notification {
val notification = repository.create(
Notification(UuidV7.generate(), tenantId, recipientId, type, title, body,
resourceType, resourceId, Instant.now(), null),
)
pushOutbox?.enqueue(notification.id)
return notification
}
fun list(actor: CurrentUser): List<Notification> {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
return repository.list(actor.tenantId, actor.id, 100)
}
fun unreadCount(actor: CurrentUser): Int {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
return repository.countUnread(actor.tenantId, actor.id)
}
fun markRead(actor: CurrentUser, id: UUID): Notification {
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
val notification = repository.markRead(actor.tenantId, actor.id, id)
?: throw ApiException(HttpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "通知不存在")
auditService.recordSuccess(actor, "NOTIFICATION_READ", "NOTIFICATION", id.toString(), null, emptyMap())
return notification
}
}
@@ -0,0 +1,43 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.notification.domain.*
import org.springframework.scheduling.annotation.Scheduled
import org.springframework.stereotype.Component
@Component
class PushDispatcher(
private val outbox: PushOutboxRepository,
private val devices: UserDeviceRepository,
private val gateway: PushGateway,
) {
@Scheduled(fixedDelayString = "\${aioa.push.dispatch-interval-ms:5000}")
fun dispatch() {
outbox.findPending(50).forEach(::dispatchOne)
}
private fun dispatchOne(pending: PendingPush) {
val notification = pending.notification
val tokens = devices.listActivePushTokens(notification.tenantId, notification.recipientId)
if (tokens.isEmpty()) return outbox.markDelivered(notification.id)
var delivered = false
var disabled = false
var failed = false
tokens.forEach { token ->
when (gateway.send(token, notification)) {
PushSendResult.DELIVERED -> delivered = true
PushSendResult.INVALID_TOKEN -> devices.clearPushToken(token)
PushSendResult.DISABLED -> disabled = true
PushSendResult.FAILED -> failed = true
}
}
when {
delivered || (!disabled && !failed) -> outbox.markDelivered(notification.id)
disabled -> outbox.reschedule(notification.id, pending.attempts, 3600, "Firebase push is not configured")
else -> {
val attempts = pending.attempts + 1
outbox.reschedule(notification.id, attempts, minOf(3600, 1L shl minOf(attempts, 10)), "Firebase delivery failed")
}
}
}
}
@@ -0,0 +1,24 @@
package com.all8ai.aioa.notification.domain
import java.time.Instant
import java.util.UUID
data class Notification(
val id: UUID,
val tenantId: UUID,
val recipientId: UUID,
val type: String,
val title: String,
val body: String,
val resourceType: String?,
val resourceId: String?,
val createdAt: Instant,
val readAt: Instant?,
)
interface NotificationRepository {
fun create(notification: Notification): Notification
fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification>
fun countUnread(tenantId: UUID, recipientId: UUID): Int
fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification?
}
@@ -0,0 +1,18 @@
package com.all8ai.aioa.notification.domain
import java.util.UUID
data class PendingPush(val notification: Notification, val attempts: Int)
interface PushOutboxRepository {
fun enqueue(notificationId: UUID)
fun findPending(limit: Int): List<PendingPush>
fun markDelivered(notificationId: UUID)
fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String)
}
enum class PushSendResult { DELIVERED, INVALID_TOKEN, DISABLED, FAILED }
fun interface PushGateway {
fun send(token: String, notification: Notification): PushSendResult
}
@@ -0,0 +1,41 @@
package com.all8ai.aioa.notification.infrastructure
import com.google.auth.oauth2.GoogleCredentials
import com.google.firebase.FirebaseApp
import com.google.firebase.FirebaseOptions
import com.google.firebase.messaging.FirebaseMessaging
import com.google.firebase.messaging.FirebaseMessagingException
import com.google.firebase.messaging.Message
import com.all8ai.aioa.notification.domain.*
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Component
import java.io.FileInputStream
@Component
class FirebasePushGateway(
@Value("\${aioa.push.firebase-credentials-file:}") credentialsFile: String,
) : PushGateway {
private val messaging: FirebaseMessaging? = credentialsFile.trim().takeIf { it.isNotEmpty() }?.let { path ->
val options = FileInputStream(path).use { FirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(it)).build() }
FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options, "aioa-push"))
}
override fun send(token: String, notification: Notification): PushSendResult {
val client = messaging ?: return PushSendResult.DISABLED
val message = Message.builder().setToken(token)
.setNotification(com.google.firebase.messaging.Notification.builder().setTitle(notification.title).setBody(notification.body).build())
.putData("type", notification.type)
.apply {
notification.resourceType?.let { putData("resourceType", it) }
notification.resourceId?.let { putData("resourceId", it) }
}.build()
return try {
client.send(message)
PushSendResult.DELIVERED
} catch (exception: FirebaseMessagingException) {
if (exception.messagingErrorCode?.name in setOf("UNREGISTERED", "INVALID_ARGUMENT")) PushSendResult.INVALID_TOKEN else PushSendResult.FAILED
} catch (_: Exception) {
PushSendResult.FAILED
}
}
}
@@ -0,0 +1,62 @@
package com.all8ai.aioa.notification.infrastructure
import com.all8ai.aioa.notification.domain.Notification
import com.all8ai.aioa.notification.domain.NotificationRepository
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqNotificationRepository(private val dsl: DSLContext) : NotificationRepository {
override fun create(notification: Notification): Notification {
dsl.execute(
"""
INSERT INTO communication.notification (
id, tenant_id, recipient_id, type, title, body,
resource_type, resource_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""".trimIndent(),
notification.id, notification.tenantId, notification.recipientId,
notification.type, notification.title, notification.body,
notification.resourceType, notification.resourceId,
)
return notification
}
override fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification> = dsl.fetch(
"""
SELECT * FROM communication.notification
WHERE tenant_id = ? AND recipient_id = ?
ORDER BY created_at DESC, id DESC LIMIT ?
""".trimIndent(),
tenantId, recipientId, limit,
).map(::map)
override fun countUnread(tenantId: UUID, recipientId: UUID): Int = dsl.fetchOne(
"SELECT COUNT(*) AS count FROM communication.notification WHERE tenant_id = ? AND recipient_id = ? AND read_at IS NULL",
tenantId, recipientId,
)!!.get("count", Int::class.java)!!
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = dsl.fetchOne(
"""
UPDATE communication.notification SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP)
WHERE tenant_id = ? AND recipient_id = ? AND id = ? RETURNING *
""".trimIndent(),
tenantId, recipientId, id,
)?.let(::map)
private fun map(record: Record) = Notification(
id = record.get("id", UUID::class.java)!!,
tenantId = record.get("tenant_id", UUID::class.java)!!,
recipientId = record.get("recipient_id", UUID::class.java)!!,
type = record.get("type", String::class.java)!!,
title = record.get("title", String::class.java)!!,
body = record.get("body", String::class.java)!!,
resourceType = record.get("resource_type", String::class.java),
resourceId = record.get("resource_id", String::class.java),
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
readAt = record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
)
}
@@ -0,0 +1,47 @@
package com.all8ai.aioa.notification.infrastructure
import com.all8ai.aioa.notification.domain.*
import org.jooq.DSLContext
import org.jooq.Record
import org.springframework.stereotype.Repository
import java.time.OffsetDateTime
import java.util.UUID
@Repository
class JooqPushOutboxRepository(private val dsl: DSLContext) : PushOutboxRepository {
override fun enqueue(notificationId: UUID) {
dsl.execute("INSERT INTO communication.notification_push_outbox (notification_id) VALUES (?) ON CONFLICT DO NOTHING", notificationId)
}
override fun findPending(limit: Int): List<PendingPush> = dsl.fetch(
"""
SELECT n.*, o.attempts FROM communication.notification_push_outbox o
JOIN communication.notification n ON n.id = o.notification_id
WHERE o.status = 'PENDING' AND o.next_attempt_at <= CURRENT_TIMESTAMP
ORDER BY o.next_attempt_at, o.notification_id LIMIT ?
""".trimIndent(), limit,
).map(::map)
override fun markDelivered(notificationId: UUID) {
dsl.execute("UPDATE communication.notification_push_outbox SET status = 'DELIVERED', delivered_at = CURRENT_TIMESTAMP WHERE notification_id = ?", notificationId)
}
override fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String) {
dsl.execute(
"UPDATE communication.notification_push_outbox SET attempts = ?, next_attempt_at = CURRENT_TIMESTAMP + (? * INTERVAL '1 second'), last_error = ? WHERE notification_id = ?",
attempts, delaySeconds, error.take(500), notificationId,
)
}
private fun map(record: Record) = PendingPush(
Notification(
record.get("id", UUID::class.java)!!, record.get("tenant_id", UUID::class.java)!!,
record.get("recipient_id", UUID::class.java)!!, record.get("type", String::class.java)!!,
record.get("title", String::class.java)!!, record.get("body", String::class.java)!!,
record.get("resource_type", String::class.java), record.get("resource_id", String::class.java),
record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
),
record.get("attempts", Int::class.java)!!,
)
}
@@ -0,0 +1,59 @@
package com.all8ai.aioa.shared.security
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.springframework.http.HttpStatus
enum class ToolPermission {
LEAVE_REQUEST_READ_OWN,
LEAVE_REQUEST_WRITE_OWN,
LEAVE_ATTACHMENT_MANAGE_OWN,
NOTIFICATION_READ_OWN,
AI_LEAVE_DRAFT_SUGGEST,
AI_LEAVE_PROGRESS_READ_OWN,
APPROVAL_TASK_READ_ASSIGNED,
APPROVAL_TASK_DECIDE_ASSIGNED,
}
enum class DataScope { OWN, ASSIGNED }
data class UserCapabilities(
val permissions: Set<ToolPermission>,
val dataScopes: Set<DataScope>,
)
object AuthorizationPolicy {
private val employeePermissions = setOf(
ToolPermission.LEAVE_REQUEST_READ_OWN,
ToolPermission.LEAVE_REQUEST_WRITE_OWN,
ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN,
ToolPermission.NOTIFICATION_READ_OWN,
ToolPermission.AI_LEAVE_DRAFT_SUGGEST,
ToolPermission.AI_LEAVE_PROGRESS_READ_OWN,
)
private val approverRoles = setOf("department_manager", "oa_admin", "hr_reviewer")
private val approvalPermissions = setOf(
ToolPermission.APPROVAL_TASK_READ_ASSIGNED,
ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED,
)
fun capabilities(user: CurrentUser): UserCapabilities {
val permissions = buildSet {
if ("employee" in user.roles) addAll(employeePermissions)
if (user.roles.any(approverRoles::contains)) addAll(approvalPermissions)
}
return UserCapabilities(
permissions,
buildSet {
if (permissions.any { it.name.endsWith("_OWN") }) add(DataScope.OWN)
if (permissions.any { it.name.endsWith("_ASSIGNED") }) add(DataScope.ASSIGNED)
},
)
}
}
fun CurrentUser.requirePermission(permission: ToolPermission) {
if (permission !in AuthorizationPolicy.capabilities(this).permissions) {
throw ApiException(HttpStatus.FORBIDDEN, "PERMISSION_DENIED", "当前用户无权使用该功能")
}
}
@@ -22,6 +22,9 @@ interface LeaveWorkflowGateway {
fun completeTask(taskId: String, approved: Boolean, comment: String?): TaskCompletion
fun cancelProcess(processInstanceId: String, reason: String)
fun getProgress(processInstanceId: String): WorkflowProgress =
error("Workflow progress is not supported")
}
data class StartedProcess(
@@ -43,3 +46,9 @@ data class TaskCompletion(
val processInstanceId: String,
val processEnded: Boolean,
)
data class WorkflowProgress(
val activeTaskNames: List<String>,
val completedTaskNames: List<String>,
val processEnded: Boolean,
)
@@ -4,6 +4,7 @@ import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.workflow.domain.StartedProcess
import com.all8ai.aioa.workflow.domain.WorkflowTask
import com.all8ai.aioa.workflow.domain.TaskCompletion
import com.all8ai.aioa.workflow.domain.WorkflowProgress
import org.flowable.engine.HistoryService
import org.flowable.engine.RuntimeService
import org.flowable.engine.TaskService
@@ -96,6 +97,21 @@ class FlowableLeaveWorkflowGateway(
runtimeService.deleteProcessInstance(processInstanceId, reason)
}
override fun getProgress(processInstanceId: String): WorkflowProgress {
val active = taskService.createTaskQuery()
.processInstanceId(processInstanceId).active().list()
.map { it.name }.distinct()
val completed = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(processInstanceId).finished().orderByHistoricTaskInstanceEndTime().asc().list()
.map { it.name }.distinct()
return WorkflowProgress(
activeTaskNames = active,
completedTaskNames = completed,
processEnded = runtimeService.createProcessInstanceQuery()
.processInstanceId(processInstanceId).singleResult() == null,
)
}
private fun mapActiveTask(task: Task): WorkflowTask = WorkflowTask(
id = task.id,
name = task.name,
@@ -44,3 +44,15 @@ flowable:
database-schema-update: ${FLOWABLE_SCHEMA_UPDATE:true}
async-executor-activate: false
history-level: audit
aioa:
ai-service:
url: ${AI_SERVICE_URL:http://127.0.0.1:8000}
object-storage:
endpoint: ${MINIO_ENDPOINT:http://127.0.0.1:9000}
access-key: ${MINIO_ROOT_USER:minioadmin}
secret-key: ${MINIO_ROOT_PASSWORD:change-me-now}
bucket: ${MINIO_BUCKET:aioa-attachments}
push:
firebase-credentials-file: ${FIREBASE_CREDENTIALS_FILE:}
dispatch-interval-ms: ${PUSH_DISPATCH_INTERVAL_MS:5000}
@@ -0,0 +1,23 @@
CREATE SCHEMA IF NOT EXISTS communication;
CREATE TABLE communication.notification (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL REFERENCES identity.tenant(id),
recipient_id UUID NOT NULL,
type VARCHAR(64) NOT NULL,
title VARCHAR(200) NOT NULL,
body VARCHAR(1000) NOT NULL,
resource_type VARCHAR(64),
resource_id VARCHAR(128),
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
read_at TIMESTAMPTZ,
CONSTRAINT fk_notification_recipient FOREIGN KEY (tenant_id, recipient_id)
REFERENCES identity.user_account(tenant_id, id)
);
CREATE INDEX idx_notification_recipient_created
ON communication.notification (tenant_id, recipient_id, created_at DESC, id DESC);
CREATE INDEX idx_notification_recipient_unread
ON communication.notification (tenant_id, recipient_id)
WHERE read_at IS NULL;
@@ -0,0 +1,19 @@
CREATE TABLE identity.user_device (
id UUID NOT NULL,
tenant_id UUID NOT NULL,
user_id UUID NOT NULL,
name VARCHAR(200) NOT NULL,
platform VARCHAR(32) NOT NULL,
app_version VARCHAR(64),
status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE',
registered_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
revoked_at TIMESTAMPTZ,
PRIMARY KEY (tenant_id, user_id, id),
CONSTRAINT fk_user_device_user FOREIGN KEY (tenant_id, user_id)
REFERENCES identity.user_account(tenant_id, id),
CONSTRAINT ck_user_device_status CHECK (status IN ('ACTIVE', 'REVOKED')),
CONSTRAINT ck_user_device_platform CHECK (platform IN ('IOS', 'ANDROID', 'OTHER'))
);
CREATE INDEX idx_user_device_owner ON identity.user_device (tenant_id, user_id, last_seen_at DESC);
@@ -0,0 +1,21 @@
ALTER TABLE identity.user_device
ADD COLUMN push_token VARCHAR(4096),
ADD COLUMN push_token_updated_at TIMESTAMPTZ;
CREATE UNIQUE INDEX uq_user_device_push_token
ON identity.user_device (push_token)
WHERE push_token IS NOT NULL AND status = 'ACTIVE';
CREATE TABLE communication.notification_push_outbox (
notification_id UUID PRIMARY KEY REFERENCES communication.notification(id) ON DELETE CASCADE,
status VARCHAR(32) NOT NULL DEFAULT 'PENDING',
attempts INTEGER NOT NULL DEFAULT 0,
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
last_error VARCHAR(500),
delivered_at TIMESTAMPTZ,
CONSTRAINT ck_push_outbox_status CHECK (status IN ('PENDING', 'DELIVERED'))
);
CREATE INDEX idx_push_outbox_pending
ON communication.notification_push_outbox (next_attempt_at, notification_id)
WHERE status = 'PENDING';
@@ -0,0 +1,23 @@
CREATE TABLE business.leave_attachment (
id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
leave_request_id UUID NOT NULL,
uploader_id UUID NOT NULL,
file_name VARCHAR(255) NOT NULL,
content_type VARCHAR(128) NOT NULL,
size_bytes BIGINT NOT NULL,
object_key VARCHAR(1024) NOT NULL,
status VARCHAR(32) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TIMESTAMPTZ,
CONSTRAINT fk_leave_attachment_request FOREIGN KEY (tenant_id, leave_request_id)
REFERENCES business.leave_request(tenant_id, id) ON DELETE CASCADE,
CONSTRAINT fk_leave_attachment_uploader FOREIGN KEY (tenant_id, uploader_id)
REFERENCES identity.user_account(tenant_id, id),
CONSTRAINT ck_leave_attachment_status CHECK (status IN ('PENDING', 'READY')),
CONSTRAINT ck_leave_attachment_size CHECK (size_bytes > 0 AND size_bytes <= 10485760),
UNIQUE (tenant_id, object_key)
);
CREATE INDEX idx_leave_attachment_request
ON business.leave_attachment (tenant_id, leave_request_id, created_at, id);
@@ -0,0 +1,49 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
import com.all8ai.aioa.ai.domain.LeaveDraftSuggestion
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.springframework.http.HttpStatus
import java.time.Instant
import java.util.UUID
class AiLeaveDraftServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
@Test
fun `returns suggestion that still requires user confirmation`() {
val gateway = AiLeaveDraftGateway {
_, _ -> SuggestedLeaveDraft(
LeaveDraftSuggestion("PERSONAL", Instant.parse("2026-07-19T05:30:00Z"),
Instant.parse("2026-07-19T09:30:00Z"), "办理个人事务", emptyList(), emptyList()),
"qwen-plus", true,
)
}
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
val result = service.suggest(actor, "明天下午请事假四小时", "Asia/Shanghai")
assertThat(result.suggestion.type).isEqualTo("PERSONAL")
assertThat(result.requiresUserConfirmation).isTrue()
}
@Test
fun `rejects model response that could bypass confirmation`() {
val gateway = AiLeaveDraftGateway { _, _ ->
SuggestedLeaveDraft(LeaveDraftSuggestion(null, null, null, null, emptyList(), emptyList()), "qwen-plus", false)
}
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
assertThatThrownBy { service.suggest(actor, "请假", "Asia/Shanghai") }
.isInstanceOfSatisfying(ApiException::class.java) {
assertThat(it.status).isEqualTo(HttpStatus.BAD_GATEWAY)
}
}
}
@@ -0,0 +1,81 @@
package com.all8ai.aioa.ai.application
import com.all8ai.aioa.ai.domain.*
import com.all8ai.aioa.approval.domain.*
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import com.all8ai.aioa.workflow.domain.LeaveWorkflowGateway
import com.all8ai.aioa.workflow.domain.WorkflowProgress
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.time.Instant
import java.util.UUID
class AiLeaveProgressServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
@Test
fun `returns candidates without calling AI when request is ambiguous`() {
val repository = mock(LeaveRequestRepository::class.java)
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(request(1), request(2)))
val gateway = CapturingGateway()
val service = service(repository, mock(LeaveWorkflowGateway::class.java), gateway)
val result = service.answer(actor, "我的请假到哪一步了", "Asia/Shanghai", null)
assertThat(result.requiresSelection).isTrue()
assertThat(result.candidates).hasSize(2)
assertThat(gateway.context).isNull()
}
@Test
fun `selected own request sends only minimal progress context to AI`() {
val selected = request(1)
val repository = mock(LeaveRequestRepository::class.java)
val workflow = mock(LeaveWorkflowGateway::class.java)
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(selected))
`when`(repository.listTimeline(actor.tenantId, selected.id)).thenReturn(emptyList())
`when`(workflow.getProgress(selected.processInstanceId!!)).thenReturn(WorkflowProgress(listOf("主管审批"), listOf("提交申请"), false))
val gateway = CapturingGateway()
val service = service(repository, workflow, gateway)
val result = service.answer(actor, "进度如何", "Asia/Shanghai", selected.id)
assertThat(result.requiresSelection).isFalse()
assertThat(result.answer).contains("审批中")
assertThat(gateway.context!!.requestId).isEqualTo(selected.id)
assertThat(gateway.context!!.activeTaskNames).containsExactly("主管审批")
verify(repository).listTimeline(actor.tenantId, selected.id)
}
@Test
fun `rejects selected request that does not belong to current user`() {
val repository = mock(LeaveRequestRepository::class.java)
`when`(repository.listOwn(actor.tenantId, actor.id, 100)).thenReturn(listOf(request(1)))
val service = service(repository, mock(LeaveWorkflowGateway::class.java), CapturingGateway())
assertThatThrownBy { service.answer(actor, "查询进度", "Asia/Shanghai", UUID.randomUUID()) }
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("LEAVE_REQUEST_NOT_FOUND") }
}
private fun service(repository: LeaveRequestRepository, workflow: LeaveWorkflowGateway, gateway: AiLeaveProgressGateway) =
AiLeaveProgressService(repository, workflow, gateway, mock(AuditService::class.java))
private fun request(number: Int): LeaveRequest {
val created = Instant.parse("2026-07-${17 + number}T01:00:00Z")
return LeaveRequest(UUID.randomUUID(), actor.tenantId, actor.id, LeaveType.ANNUAL, created, created.plusSeconds(28800), "私人原因", LeaveStatus.PENDING, created, created, 1, "process-$number", "definition")
}
private class CapturingGateway : AiLeaveProgressGateway {
var context: LeaveProgressContext? = null
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer {
this.context = context
return GeneratedProgressAnswer("当前状态为审批中,正在主管审批。", "qwen-plus")
}
}
}
@@ -0,0 +1,103 @@
package com.all8ai.aioa.attachment.application
import com.all8ai.aioa.approval.domain.LeaveRequest
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
import com.all8ai.aioa.approval.domain.LeaveStatus
import com.all8ai.aioa.approval.domain.LeaveType
import com.all8ai.aioa.attachment.domain.AttachmentStatus
import com.all8ai.aioa.attachment.domain.LeaveAttachment
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
import com.all8ai.aioa.attachment.domain.StoredObject
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import org.springframework.http.HttpStatus
import java.time.Instant
import java.util.UUID
class LeaveAttachmentServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
private val requestId = UUID.randomUUID()
@Test
fun `creates upload task only for an owned draft`() {
val attachments = InMemoryAttachments()
val storage = FakeStorage()
val service = service(attachments, storage, LeaveStatus.DRAFT)
val upload = service.createUpload(actor, requestId, CreateUploadCommand("证明.pdf", "application/pdf", 1024))
assertThat(upload.attachment.status).isEqualTo(AttachmentStatus.PENDING)
assertThat(upload.uploadUrl).startsWith("https://minio.test/upload/")
assertThat(upload.attachment.objectKey).startsWith("${actor.tenantId}/leave/$requestId/")
}
@Test
fun `rejects executable attachment types`() {
val service = service(InMemoryAttachments(), FakeStorage(), LeaveStatus.DRAFT)
assertThatThrownBy {
service.createUpload(actor, requestId, CreateUploadCommand("tool.exe", "application/octet-stream", 100))
}.isInstanceOfSatisfying(ApiException::class.java) {
assertThat(it.status).isEqualTo(HttpStatus.BAD_REQUEST)
assertThat(it.code).isEqualTo("ATTACHMENT_TYPE_INVALID")
}
}
@Test
fun `verifies object size before marking attachment ready`() {
val attachments = InMemoryAttachments()
val storage = FakeStorage()
val service = service(attachments, storage, LeaveStatus.DRAFT)
val upload = service.createUpload(actor, requestId, CreateUploadCommand("photo.png", "image/png", 2048))
storage.objects[upload.attachment.objectKey] = StoredObject(2048, "image/png")
val completed = service.complete(actor, requestId, upload.attachment.id)
assertThat(completed.status).isEqualTo(AttachmentStatus.READY)
assertThat(completed.completedAt).isNotNull()
}
private fun service(
attachments: LeaveAttachmentRepository,
storage: ObjectStorageGateway,
status: LeaveStatus,
): LeaveAttachmentService {
val requests = mock(LeaveRequestRepository::class.java)
val audit = mock(AuditService::class.java)
`when`(requests.findOwn(actor.tenantId, actor.id, requestId)).thenReturn(
LeaveRequest(requestId, actor.tenantId, actor.id, LeaveType.PERSONAL,
Instant.parse("2026-07-20T01:00:00Z"), Instant.parse("2026-07-20T05:00:00Z"),
"个人事务", status, Instant.now(), Instant.now(), 0),
)
return LeaveAttachmentService(requests, attachments, storage, audit)
}
private class InMemoryAttachments : LeaveAttachmentRepository {
private val values = linkedMapOf<UUID, LeaveAttachment>()
override fun create(attachment: LeaveAttachment) = attachment.also { values[it.id] = it }
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID) =
values[attachmentId]?.takeIf { it.tenantId == tenantId && it.leaveRequestId == leaveRequestId }
override fun list(tenantId: UUID, leaveRequestId: UUID) =
values.values.filter { it.tenantId == tenantId && it.leaveRequestId == leaveRequestId }
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = values[attachmentId]
?.takeIf { it.tenantId == tenantId && it.status == AttachmentStatus.PENDING }
?.copy(status = AttachmentStatus.READY, completedAt = Instant.now())
?.also { values[attachmentId] = it }
override fun delete(tenantId: UUID, attachmentId: UUID) = values.remove(attachmentId) != null
}
private class FakeStorage : ObjectStorageGateway {
val objects = mutableMapOf<String, StoredObject>()
override fun createUploadUrl(objectKey: String) = "https://minio.test/upload/$objectKey"
override fun stat(objectKey: String) = objects[objectKey] ?: error("missing object")
override fun createDownloadUrl(objectKey: String) = "https://minio.test/download/$objectKey"
override fun delete(objectKey: String) { objects.remove(objectKey) }
}
}
@@ -0,0 +1,42 @@
package com.all8ai.aioa.device.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.device.domain.*
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.`when`
import java.time.Instant
import java.util.UUID
class UserDeviceServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
@Test
fun `registers active device for current user`() {
val repository = mock(UserDeviceRepository::class.java)
val id = UUID.randomUUID()
val device = device(id, DeviceStatus.ACTIVE)
`when`(repository.register(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, "1.0.0")).thenReturn(device)
val result = service(repository).register(actor, id, "iPhone", DevicePlatform.IOS, "1.0.0")
assertThat(result).isEqualTo(device)
}
@Test
fun `revoked device cannot register again`() {
val repository = mock(UserDeviceRepository::class.java)
val id = UUID.randomUUID()
`when`(repository.register(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, null)).thenReturn(null)
assertThatThrownBy { service(repository).register(actor, id, "iPhone", DevicePlatform.IOS, null) }
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("DEVICE_REVOKED") }
}
private fun service(repository: UserDeviceRepository) = UserDeviceService(repository, mock(AuditService::class.java))
private fun device(id: UUID, status: DeviceStatus) = UserDevice(id, actor.tenantId, actor.id, "iPhone", DevicePlatform.IOS, "1.0.0", status, Instant.now(), Instant.now(), null)
}
@@ -0,0 +1,29 @@
package com.all8ai.aioa.forms.api
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.http.HttpStatus
class FormDefinitionControllerTest {
private val controller = FormDefinitionController()
@Test
fun `returns versioned leave request schemas`() {
val response = controller.getDefinition("leave-request")
assertThat(response.key).isEqualTo("leave-request")
assertThat(response.version).isEqualTo(1)
assertThat(response.dataSchema["\$id"]).isEqualTo("leave-request-v1")
assertThat(response.uiSchema["sections"]).isInstanceOf(List::class.java)
}
@Test
fun `rejects unknown form keys`() {
val exception = assertThrows<ApiException> { controller.getDefinition("unknown") }
assertThat(exception.status).isEqualTo(HttpStatus.NOT_FOUND)
assertThat(exception.code).isEqualTo("FORM_DEFINITION_NOT_FOUND")
}
}
@@ -0,0 +1,72 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.audit.application.AuditService
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.notification.domain.Notification
import com.all8ai.aioa.notification.domain.NotificationRepository
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.springframework.http.HttpStatus
import java.time.Instant
import java.util.UUID
class NotificationServiceTest {
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
@Test
fun `lists only recipient notifications and tracks unread count`() {
val repository = InMemoryNotifications()
val service = NotificationService(repository, mock(AuditService::class.java))
service.notify(actor.tenantId, actor.id, "LEAVE_APPROVED", "已通过", "申请已通过")
service.notify(actor.tenantId, UUID.randomUUID(), "OTHER", "其他人", "不可见")
assertThat(service.list(actor)).hasSize(1)
assertThat(service.unreadCount(actor)).isEqualTo(1)
}
@Test
fun `marks own notification read idempotently`() {
val repository = InMemoryNotifications()
val service = NotificationService(repository, mock(AuditService::class.java))
val notification = service.notify(actor.tenantId, actor.id, "TASK", "待办", "请审批")
val read = service.markRead(actor, notification.id)
val replay = service.markRead(actor, notification.id)
assertThat(read.readAt).isNotNull()
assertThat(replay.readAt).isEqualTo(read.readAt)
assertThat(service.unreadCount(actor)).isZero()
}
@Test
fun `does not reveal another users notification`() {
val repository = InMemoryNotifications()
val service = NotificationService(repository, mock(AuditService::class.java))
val notification = service.notify(actor.tenantId, UUID.randomUUID(), "TASK", "待办", "不可见")
assertThatThrownBy { service.markRead(actor, notification.id) }
.isInstanceOfSatisfying(ApiException::class.java) {
assertThat(it.status).isEqualTo(HttpStatus.NOT_FOUND)
}
}
private class InMemoryNotifications : NotificationRepository {
private val values = linkedMapOf<UUID, Notification>()
override fun create(notification: Notification) = notification.also { values[it.id] = it }
override fun list(tenantId: UUID, recipientId: UUID, limit: Int) = values.values
.filter { it.tenantId == tenantId && it.recipientId == recipientId }
.sortedByDescending { it.createdAt }
.take(limit)
override fun countUnread(tenantId: UUID, recipientId: UUID) = values.values.count {
it.tenantId == tenantId && it.recipientId == recipientId && it.readAt == null
}
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = values[id]
?.takeIf { it.tenantId == tenantId && it.recipientId == recipientId }
?.let { existing ->
existing.copy(readAt = existing.readAt ?: Instant.now()).also { values[id] = it }
}
}
}
@@ -0,0 +1,43 @@
package com.all8ai.aioa.notification.application
import com.all8ai.aioa.device.domain.UserDeviceRepository
import com.all8ai.aioa.notification.domain.*
import org.junit.jupiter.api.Test
import org.mockito.Mockito.mock
import org.mockito.Mockito.verify
import org.mockito.Mockito.`when`
import java.time.Instant
import java.util.UUID
class PushDispatcherTest {
@Test
fun `delivers pending notification to active device token`() {
val outbox = mock(PushOutboxRepository::class.java)
val devices = mock(UserDeviceRepository::class.java)
val gateway = mock(PushGateway::class.java)
val notification = Notification(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "LEAVE_APPROVED", "已通过", "申请已通过", "LEAVE_REQUEST", "leave-1", Instant.now(), null)
`when`(outbox.findPending(50)).thenReturn(listOf(PendingPush(notification, 0)))
`when`(devices.listActivePushTokens(notification.tenantId, notification.recipientId)).thenReturn(listOf("token-1"))
`when`(gateway.send("token-1", notification)).thenReturn(PushSendResult.DELIVERED)
PushDispatcher(outbox, devices, gateway).dispatch()
verify(outbox).markDelivered(notification.id)
}
@Test
fun `removes invalid device token and completes outbox item`() {
val outbox = mock(PushOutboxRepository::class.java)
val devices = mock(UserDeviceRepository::class.java)
val gateway = mock(PushGateway::class.java)
val notification = Notification(UUID.randomUUID(), UUID.randomUUID(), UUID.randomUUID(), "TASK", "待办", "新待办", null, null, Instant.now(), null)
`when`(outbox.findPending(50)).thenReturn(listOf(PendingPush(notification, 0)))
`when`(devices.listActivePushTokens(notification.tenantId, notification.recipientId)).thenReturn(listOf("expired"))
`when`(gateway.send("expired", notification)).thenReturn(PushSendResult.INVALID_TOKEN)
PushDispatcher(outbox, devices, gateway).dispatch()
verify(devices).clearPushToken("expired")
verify(outbox).markDelivered(notification.id)
}
}
@@ -0,0 +1,39 @@
package com.all8ai.aioa.shared.security
import com.all8ai.aioa.identity.domain.CurrentUser
import com.all8ai.aioa.shared.web.ApiException
import org.assertj.core.api.Assertions.assertThat
import org.assertj.core.api.Assertions.assertThatThrownBy
import org.junit.jupiter.api.Test
import java.util.UUID
class AuthorizationPolicyTest {
@Test
fun `employee receives only own-data tools`() {
val capabilities = AuthorizationPolicy.capabilities(user(setOf("employee")))
assertThat(capabilities.dataScopes).containsExactly(DataScope.OWN)
assertThat(capabilities.permissions).contains(ToolPermission.LEAVE_REQUEST_WRITE_OWN)
assertThat(capabilities.permissions).doesNotContain(ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
}
@Test
fun `all approver roles receive assigned-task tools`() {
for (role in setOf("department_manager", "oa_admin", "hr_reviewer")) {
val capabilities = AuthorizationPolicy.capabilities(user(setOf("employee", role)))
assertThat(capabilities.dataScopes).contains(DataScope.OWN, DataScope.ASSIGNED)
assertThat(capabilities.permissions).contains(ToolPermission.APPROVAL_TASK_READ_ASSIGNED, ToolPermission.APPROVAL_TASK_DECIDE_ASSIGNED)
}
}
@Test
fun `unknown role cannot use employee tools`() {
val actor = user(setOf("unknown"))
assertThatThrownBy { actor.requirePermission(ToolPermission.LEAVE_REQUEST_READ_OWN) }
.isInstanceOfSatisfying(ApiException::class.java) { assertThat(it.code).isEqualTo("PERMISSION_DENIED") }
}
private fun user(roles: Set<String>) = CurrentUser(
UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles,
)
}
+333 -2
View File
@@ -1,10 +1,170 @@
openapi: 3.1.0
info:
title: AIOA API
version: 0.4.0
version: 0.12.0
servers:
- url: /api/v1
security:
- bearerAuth: []
paths:
/devices/register:
post:
operationId: registerCurrentDevice
summary: 注册或刷新当前用户设备会话
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [id, name, platform]
properties:
id: { type: string, format: uuid }
name: { type: string, minLength: 1, maxLength: 200 }
platform: { type: string, enum: [IOS, ANDROID, OTHER] }
appVersion: { type: [string, "null"], maxLength: 64 }
responses:
"200": { description: 已注册的设备会话 }
"401": { description: 设备已被撤销,禁止重新注册 }
/devices:
get:
operationId: listOwnDevices
summary: 查询当前用户的登录设备
responses:
"200": { description: 当前用户设备列表 }
/devices/{id}:
delete:
operationId: revokeOwnDevice
summary: 撤销当前用户的一台设备并使其后续请求失效
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
responses:
"200": { description: 已撤销设备 }
"404": { description: 设备不存在或不属于当前用户 }
/devices/{id}/push-token:
put:
operationId: updateCurrentDevicePushToken
summary: 注册、刷新或清除当前设备的 FCM 推送令牌
parameters:
- { name: id, in: path, required: true, schema: { type: string, format: uuid } }
- { name: X-AIOA-Device-Id, in: header, required: true, schema: { type: string, format: uuid } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
token: { type: [string, "null"], maxLength: 4096 }
responses:
"200": { description: 推送令牌已更新 }
"403": { description: 路径设备与当前设备不一致 }
/ai/leave-progress-answers:
post:
operationId: answerOwnLeaveProgress
summary: 使用自然语言只读查询本人请假流程进度
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text, timezone]
properties:
text: { type: string, minLength: 1, maxLength: 2000 }
timezone: { type: string, default: Asia/Shanghai }
selectedRequestId: { type: string, format: uuid }
responses:
"200":
description: 回答或需要用户选择的本人申请候选列表
"400": { $ref: "#/components/responses/BadRequest" }
"502":
description: AI 上游不可用或输出无效
/ai/leave-draft-suggestions:
post:
operationId: suggestLeaveDraft
summary: 将自然语言转换为需要用户确认的请假草稿建议
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text, timezone]
properties:
text: { type: string, minLength: 1, maxLength: 2000 }
timezone: { type: string, default: Asia/Shanghai }
responses:
"200":
description: 受约束的草稿建议,不执行业务写操作
content:
application/json:
schema: { $ref: "#/components/schemas/AiLeaveDraftSuggestion" }
"400": { $ref: "#/components/responses/BadRequest" }
"502":
description: AI 上游不可用或输出无效
/notifications:
get:
operationId: listOwnNotifications
summary: 查询当前用户站内通知
responses:
"200":
description: 通知列表
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/Notification" }
/notifications/unread-count:
get:
operationId: getUnreadNotificationCount
summary: 查询未读通知数
responses:
"200":
description: 未读数
content:
application/json:
schema:
type: object
required: [unreadCount]
properties:
unreadCount: { type: integer, minimum: 0 }
/notifications/{id}/read:
post:
operationId: markNotificationRead
summary: 标记本人通知已读
parameters:
- name: id
in: path
required: true
schema: { type: string, format: uuid }
responses:
"200":
description: 已读通知
content:
application/json:
schema: { $ref: "#/components/schemas/Notification" }
"404": { $ref: "#/components/responses/NotFound" }
/form-definitions/{formKey}:
get:
operationId: getFormDefinition
summary: 获取版本化表单定义
parameters:
- name: formKey
in: path
required: true
schema: { type: string }
responses:
"200":
description: JSON Schema 与移动端 UI Schema
content:
application/json:
schema:
$ref: "#/components/schemas/FormDefinition"
"401":
$ref: "#/components/responses/Unauthorized"
"404":
$ref: "#/components/responses/NotFound"
/me:
get:
operationId: getCurrentUser
@@ -169,6 +329,81 @@ paths:
$ref: "#/components/schemas/LeaveRequestEvent"
"404":
$ref: "#/components/responses/NotFound"
/leave-requests/{id}/attachments/upload-tasks:
post:
operationId: createLeaveAttachmentUpload
summary: 创建附件直传任务
parameters:
- $ref: "#/components/parameters/LeaveRequestId"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateAttachmentUpload"
responses:
"201":
description: 已创建上传任务
content:
application/json:
schema:
$ref: "#/components/schemas/AttachmentUpload"
"400": { $ref: "#/components/responses/BadRequest" }
"404": { $ref: "#/components/responses/NotFound" }
"409": { $ref: "#/components/responses/Conflict" }
/leave-requests/{id}/attachments:
get:
operationId: listLeaveAttachments
summary: 查询请假附件
parameters:
- $ref: "#/components/parameters/LeaveRequestId"
responses:
"200":
description: 附件列表
content:
application/json:
schema:
type: array
items: { $ref: "#/components/schemas/LeaveAttachment" }
/leave-requests/{id}/attachments/{attachmentId}/complete:
post:
operationId: completeLeaveAttachmentUpload
summary: 确认附件上传完成
parameters:
- $ref: "#/components/parameters/LeaveRequestId"
- $ref: "#/components/parameters/AttachmentId"
responses:
"200":
description: 附件已就绪
content:
application/json:
schema: { $ref: "#/components/schemas/LeaveAttachment" }
/leave-requests/{id}/attachments/{attachmentId}/download:
get:
operationId: createLeaveAttachmentDownload
summary: 获取附件短期下载地址
parameters:
- $ref: "#/components/parameters/LeaveRequestId"
- $ref: "#/components/parameters/AttachmentId"
responses:
"200":
description: 短期下载地址
content:
application/json:
schema:
type: object
required: [downloadUrl]
properties:
downloadUrl: { type: string, format: uri }
/leave-requests/{id}/attachments/{attachmentId}:
delete:
operationId: deleteLeaveAttachment
summary: 删除草稿附件
parameters:
- $ref: "#/components/parameters/LeaveRequestId"
- $ref: "#/components/parameters/AttachmentId"
responses:
"204": { description: 已删除 }
/approval-tasks:
get:
operationId: listAssignedApprovalTasks
@@ -231,6 +466,17 @@ paths:
"409":
$ref: "#/components/responses/Conflict"
components:
securitySchemes:
bearerAuth:
type: oauth2
flows:
authorizationCode:
authorizationUrl: http://localhost:8081/realms/aioa/protocol/openid-connect/auth
tokenUrl: http://localhost:8081/realms/aioa/protocol/openid-connect/token
scopes:
openid: OpenID Connect identity
profile: Basic profile
email: Email address
parameters:
LeaveRequestId:
name: id
@@ -250,6 +496,11 @@ components:
in: path
required: true
schema: { type: string }
AttachmentId:
name: attachmentId
in: path
required: true
schema: { type: string, format: uuid }
responses:
BadRequest:
description: 请求无效
@@ -282,9 +533,79 @@ components:
schema:
$ref: "#/components/schemas/Problem"
schemas:
AiLeaveDraftSuggestion:
type: object
required: [suggestion, model, requiresUserConfirmation]
properties:
suggestion:
type: object
required: [assumptions, needsClarification]
properties:
type: { type: [string, "null"], enum: [PERSONAL, SICK, ANNUAL, null] }
startsAt: { type: [string, "null"], format: date-time }
endsAt: { type: [string, "null"], format: date-time }
reason: { type: [string, "null"], maxLength: 2000 }
assumptions:
type: array
items: { type: string }
needsClarification:
type: array
items: { type: string }
model: { type: string }
requiresUserConfirmation: { type: boolean, const: true }
Notification:
type: object
required: [id, type, title, body, createdAt]
properties:
id: { type: string, format: uuid }
type: { type: string }
title: { type: string }
body: { type: string }
resourceType: { type: [string, "null"] }
resourceId: { type: [string, "null"] }
createdAt: { type: string, format: date-time }
readAt: { type: [string, "null"], format: date-time }
CreateAttachmentUpload:
type: object
required: [fileName, contentType, sizeBytes]
properties:
fileName: { type: string, minLength: 1, maxLength: 255 }
contentType:
type: string
enum: [image/jpeg, image/png, application/pdf]
sizeBytes: { type: integer, format: int64, minimum: 1, maximum: 10485760 }
AttachmentUpload:
type: object
required: [attachment, uploadUrl]
properties:
attachment: { $ref: "#/components/schemas/LeaveAttachment" }
uploadUrl: { type: string, format: uri }
LeaveAttachment:
type: object
required: [id, fileName, contentType, sizeBytes, status, createdAt]
properties:
id: { type: string, format: uuid }
fileName: { type: string }
contentType: { type: string }
sizeBytes: { type: integer, format: int64 }
status: { type: string, enum: [PENDING, READY] }
createdAt: { type: string, format: date-time }
completedAt: { type: [string, "null"], format: date-time }
FormDefinition:
type: object
required: [key, version, dataSchema, uiSchema]
properties:
key: { type: string }
version: { type: integer, minimum: 1 }
dataSchema:
type: object
additionalProperties: true
uiSchema:
type: object
additionalProperties: true
CurrentUser:
type: object
required: [id, tenantId, username, displayName, roles]
required: [id, tenantId, username, displayName, roles, permissions, dataScopes]
properties:
id: { type: string, format: uuid }
tenantId: { type: string, format: uuid }
@@ -305,6 +626,16 @@ components:
type: array
uniqueItems: true
items: { type: string }
permissions:
type: array
uniqueItems: true
items:
type: string
enum: [LEAVE_REQUEST_READ_OWN, LEAVE_REQUEST_WRITE_OWN, LEAVE_ATTACHMENT_MANAGE_OWN, NOTIFICATION_READ_OWN, AI_LEAVE_DRAFT_SUGGEST, AI_LEAVE_PROGRESS_READ_OWN, APPROVAL_TASK_READ_ASSIGNED, APPROVAL_TASK_DECIDE_ASSIGNED]
dataScopes:
type: array
uniqueItems: true
items: { type: string, enum: [OWN, ASSIGNED] }
OrganizationRef:
type: object
required: [id, name]
+14
View File
@@ -51,6 +51,20 @@ services:
volumes:
- minio-data:/data
ai-service:
build:
context: ../../ai-service
environment:
QWEN_API_KEY: ${QWEN_API_KEY:-}
QWEN_MODEL: ${QWEN_MODEL:-qwen-plus}
ports:
- "8000:8000"
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health')"]
interval: 10s
timeout: 5s
retries: 10
volumes:
postgres-data-v1:
redis-data:
+1 -1
View File
@@ -21,7 +21,7 @@
"publicClient": true,
"standardFlowEnabled": true,
"directAccessGrantsEnabled": true,
"redirectUris": ["aioa://oauth/callback", "http://localhost:*"],
"redirectUris": ["aioa://oauth/callback", "aioa://oauth/logout", "http://localhost:*"],
"webOrigins": ["+"],
"attributes": {
"pkce.code.challenge.method": "S256"
+24
View File
@@ -0,0 +1,24 @@
# 数据范围与工具权限
后端以 OA 数据库中的当前用户角色为授权事实来源,不信任 Flutter 传入的角色、租户或数据范围。
## 数据范围
- `OWN`:只能访问当前租户、当前用户自己的申请、附件、通知和 AI 查询上下文。
- `ASSIGNED`:只能访问 Flowable 明确分配给当前用户的审批任务。
系统暂不提供全租户申请列表,因此 `oa_admin``hr_reviewer` 也不能绕过流程任务查看任意员工申请。
## 角色与权限
普通员工 `employee` 获得本人请假、附件、通知和只读 AI 工具权限。
以下任一角色额外获得已分配审批任务的读取和处理权限:
- `department_manager`
- `oa_admin`
- `hr_reviewer`
`GET /api/v1/me` 返回服务端计算的 `permissions``dataScopes`,客户端可据此展示工具;应用服务仍会再次校验权限,客户端隐藏按钮不能替代服务端授权。
权限不足统一返回 HTTP 403 和错误码 `PERMISSION_DENIED`。对不属于当前用户的数据和任务继续返回 404,避免泄露资源是否存在。
+18
View File
@@ -44,3 +44,21 @@ AI 可以根据自然语言生成表单字段建议值,但不能:
## 当前演示
工作台点击“发起请假”后,请假类型、开始时间、结束时间和原因均由 Schema 渲染。点击“AI 自动填写”只更新草稿状态,点击“检查并确认”后才展示最终确认卡片。
客户端优先从受保护的 `/api/v1/form-definitions/leave-request` 加载最新定义,成功后写入本地缓存。网络或认证暂不可用时优先读取最近一次有效缓存;首次离线则使用随客户端发布并经过测试的安全内置定义。运行时可通过 `--dart-define=AIOA_API_BASE_URL=...``--dart-define=AIOA_ACCESS_TOKEN=...` 配置 API 与开发令牌。
未完成的表单值会在每次字段修改或应用 AI 建议后自动写入本机。再次进入页面时自动恢复并显示来源提示;损坏或不兼容的本地草稿会被隔离清除,避免阻断表单页面。用户可显式清除草稿,后续真实提交成功后也必须清除本地副本。
用户在确认卡片中确认后,客户端调用后端草稿创建 API。请求发出前会持久化请求体与 `Idempotency-Key`;断网或服务端暂时不可用时保留待重试记录,同一份表单再次提交会复用原幂等键,后端成功响应后才清除待重试记录和本地表单草稿。
附件内容不进入 PostgreSQL 或 Flowable 变量。后端为本人草稿创建 MinIO 短期预签名上传任务,客户端直传后通知后端完成;后端重新校验对象大小和 MIME 类型,再将元数据状态改为 `READY`。下载每次重新鉴权并生成短期 URL,上传、完成、下载和删除均写入审计记录。
请假提交成功后为部门主管生成站内待办通知;流程最终批准或驳回后为申请人生成结果通知。Flutter“待办”页包含通知列表和未读数量,点击未读通知后调用后端标记已读。推送仅作为后续提醒通道,权威通知内容与已读状态保存在 PostgreSQL。
主管在 Flutter 待办卡片中查看请假类型、时间与原因,并输入审批意见后批准或驳回。审批请求在本机持久化请求体和幂等键;弱网重试复用同一 `Idempotency-Key`,成功后从列表移除当前任务。
员工可从工作台进入“我的请假申请”,查看草稿和历史申请。草稿可以提交到 Flowable,待审批申请可以撤回;详情页展示权威状态和审批事件时间线。提交与撤回均持久化幂等键,弱网重试不会重复启动或取消流程。
自然语言草稿使用千问 `qwen-plus`Flutter 只调用受保护的 Kotlin API,Kotlin 记录模型名、提示长度和澄清项数量后调用 Python AI 服务,Python 才持有供应商密钥。模型输出必须通过字段白名单、请假类型枚举、ISO-8601 时区、时间范围和文本长度校验;建议只填入本地草稿,仍需用户确认并由 Kotlin 业务接口重新校验。
Flutter 使用 Keycloak Authorization Code + PKCE 登录。Access Token、Refresh Token 和 ID Token 保存在系统安全存储中,访问令牌在过期前自动刷新;统一 HTTP 客户端只向 Kotlin API Origin 注入 Bearer Token,不会把令牌发送给 MinIO 预签名地址或千问。退出时调用 OIDC End Session 并清除本机凭据。
+16 -8
View File
@@ -7,17 +7,18 @@
- [x] OpenAPI 基础契约
- [x] Kotlin 后端 Wrapper、测试和构建入口
- [x] Trace ID、统一异常和数据库基础迁移
- [ ] GitLab CI 格式检查、测试和构建流水线
- [x] GitLab CI 格式检查、测试和 Android 构建流水线
## M1:身份与组织
- [x] Keycloak Realm、移动客户端和开发测试身份
- [x] Flutter OIDC Authorization Code + PKCE、安全 Token 存储、刷新与退出
- [x] 当前用户 OA 数据查询
- [x] 租户、部门、人员、岗位、任职关系和角色基础模型
- [x] 统一鉴权、错误结构、Trace ID 和审计基础
- [x] Keycloak 与后端容器端到端验证
- [ ] 设备注册、撤销与远程注销
- [ ] 数据范围与工具权限
- [x] 设备注册、撤销与远程注销
- [x] 数据范围与工具权限
## M2:请假审批闭环
@@ -31,14 +32,21 @@
- [x] 按请假时长路由的主管与 OA 条件串行审批
- [x] 长期年假的 OA 与 HR 并行会签及任一驳回终止
- [x] Flutter 工程、四栏导航和 Schema 驱动请假表单卡片
- [ ] 后端表单定义 API、客户端远程加载与离线缓存
- 附件、通知、弱网恢复和幂等处理
- [x] 后端表单定义 API、客户端远程加载与离线缓存
- [x] 移动端未完成草稿自动保存、损坏隔离与弱网恢复
- [x] 移动端后端草稿创建与失败提交幂等重试
- [x] 请假附件元数据、MinIO 预签名上传、完成校验、下载与删除 API
- [x] Flutter 附件选择、上传进度、MinIO 直传与附件卡片
- [x] 站内通知、未读数、已读状态、审批待办与结果通知
- [x] Flutter 主管待办列表、批准驳回与幂等安全重试
- [x] Flutter 我的申请、提交、撤回、详情和流程时间线
- [x] APNs / FCM 推送(Firebase 配置存在时启用,缺失时降级站内通知)
## M3AI 最小闭环
- 自然语言生成请假草稿
- 查询本人流程进度
- 确认卡片、工具鉴权和 AI 审计
- [x] 千问自然语言生成受约束请假草稿建议
- [x] 查询本人流程进度
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
## Definition of Done
+1
View File
@@ -23,6 +23,7 @@ android {
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
manifestPlaceholders["appAuthRedirectScheme"] = "aioa"
}
buildTypes {
@@ -7,7 +7,6 @@
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
#include "Generated.xcconfig"
+1
View File
@@ -1 +1,2 @@
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
#include "Generated.xcconfig"
+42
View File
@@ -0,0 +1,42 @@
platform :ios, '15.0'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
ENV['COCOAPODS_DISABLE_STATS'] = 'true'
project 'Runner', {
'Debug' => :debug,
'Profile' => :release,
'Release' => :release,
}
def flutter_root
generated_xcode_build_settings_path = File.expand_path(File.join('..', 'Flutter', 'Generated.xcconfig'), __FILE__)
unless File.exist?(generated_xcode_build_settings_path)
raise "#{generated_xcode_build_settings_path} must exist. If you're running pod install manually, make sure flutter pub get is executed first"
end
File.foreach(generated_xcode_build_settings_path) do |line|
matches = line.match(/FLUTTER_ROOT\=(.*)/)
return matches[1].strip if matches
end
raise "FLUTTER_ROOT not found in #{generated_xcode_build_settings_path}. Try deleting Generated.xcconfig, then run flutter pub get"
end
require File.expand_path(File.join('packages', 'flutter_tools', 'bin', 'podhelper'), flutter_root)
flutter_ios_podfile_setup
target 'Runner' do
use_frameworks!
flutter_install_all_ios_pods File.dirname(File.realpath(__FILE__))
target 'RunnerTests' do
inherit! :search_paths
end
end
post_install do |installer|
installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target)
end
end
+189
View File
@@ -0,0 +1,189 @@
PODS:
- AppAuth (2.0.0):
- AppAuth/Core (= 2.0.0)
- AppAuth/ExternalUserAgent (= 2.0.0)
- AppAuth/Core (2.0.0)
- AppAuth/ExternalUserAgent (2.0.0):
- AppAuth/Core
- DKImagePickerController/Core (4.3.9):
- DKImagePickerController/ImageDataManager
- DKImagePickerController/Resource
- DKImagePickerController/ImageDataManager (4.3.9)
- DKImagePickerController/PhotoGallery (4.3.9):
- DKImagePickerController/Core
- DKPhotoGallery
- DKImagePickerController/Resource (4.3.9)
- DKPhotoGallery (0.0.19):
- DKPhotoGallery/Core (= 0.0.19)
- DKPhotoGallery/Model (= 0.0.19)
- DKPhotoGallery/Preview (= 0.0.19)
- DKPhotoGallery/Resource (= 0.0.19)
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Core (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Preview
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Model (0.0.19):
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Preview (0.0.19):
- DKPhotoGallery/Model
- DKPhotoGallery/Resource
- SDWebImage
- SwiftyGif
- DKPhotoGallery/Resource (0.0.19):
- SDWebImage
- SwiftyGif
- file_picker (0.0.1):
- DKImagePickerController/PhotoGallery
- Flutter
- Firebase/CoreOnly (12.15.0):
- FirebaseCore (~> 12.15.0)
- Firebase/Messaging (12.15.0):
- Firebase/CoreOnly
- FirebaseMessaging (~> 12.15.0)
- firebase_core (4.12.1):
- Firebase/CoreOnly (= 12.15.0)
- Flutter
- firebase_messaging (16.4.3):
- Firebase/Messaging (= 12.15.0)
- firebase_core
- Flutter
- FirebaseCore (12.15.0):
- FirebaseCoreInternal (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
- FirebaseCoreInternal (12.15.0):
- "GoogleUtilities/NSData+zlib (~> 8.1)"
- FirebaseInstallations (12.15.0):
- FirebaseCore (~> 12.15.0)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- PromisesObjC (~> 2.4)
- FirebaseMessaging (12.15.0):
- FirebaseCore (~> 12.15.0)
- FirebaseInstallations (~> 12.15.0)
- GoogleDataTransport (~> 10.1)
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Reachability (~> 8.1)
- GoogleUtilities/UserDefaults (~> 8.1)
- nanopb (~> 3.30910.0)
- Flutter (1.0.0)
- flutter_appauth (0.0.1):
- AppAuth (= 2.0.0)
- Flutter
- flutter_secure_storage_darwin (10.0.0):
- Flutter
- FlutterMacOS
- GoogleDataTransport (10.1.0):
- nanopb (~> 3.30910.0)
- PromisesObjC (~> 2.4)
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (8.1.0):
- GoogleUtilities/Privacy
- GoogleUtilities/Logger (8.1.0):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/Network (8.1.0):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (8.1.0)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (8.1.0)
- GoogleUtilities/Reachability (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/UserDefaults (8.1.0):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- nanopb (3.30910.0):
- nanopb/decode (= 3.30910.0)
- nanopb/encode (= 3.30910.0)
- nanopb/decode (3.30910.0)
- nanopb/encode (3.30910.0)
- PromisesObjC (2.4.0)
- SDWebImage (5.21.7):
- SDWebImage/Core (= 5.21.7)
- SDWebImage/Core (5.21.7)
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- SwiftyGif (5.4.5)
DEPENDENCIES:
- file_picker (from `.symlinks/plugins/file_picker/ios`)
- firebase_core (from `.symlinks/plugins/firebase_core/ios`)
- firebase_messaging (from `.symlinks/plugins/firebase_messaging/ios`)
- Flutter (from `Flutter`)
- flutter_appauth (from `.symlinks/plugins/flutter_appauth/ios`)
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
SPEC REPOS:
trunk:
- AppAuth
- DKImagePickerController
- DKPhotoGallery
- Firebase
- FirebaseCore
- FirebaseCoreInternal
- FirebaseInstallations
- FirebaseMessaging
- GoogleDataTransport
- GoogleUtilities
- nanopb
- PromisesObjC
- SDWebImage
- SwiftyGif
EXTERNAL SOURCES:
file_picker:
:path: ".symlinks/plugins/file_picker/ios"
firebase_core:
:path: ".symlinks/plugins/firebase_core/ios"
firebase_messaging:
:path: ".symlinks/plugins/firebase_messaging/ios"
Flutter:
:path: Flutter
flutter_appauth:
:path: ".symlinks/plugins/flutter_appauth/ios"
flutter_secure_storage_darwin:
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
SPEC CHECKSUMS:
AppAuth: 1c1a8afa7e12f2ec3a294d9882dfa5ab7d3cb063
DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
file_picker: a0560bc09d61de87f12d246fc47d2119e6ef37be
Firebase: a8539b633d474fbeb654c7043f9c1649e274045b
firebase_core: 1c1e250a77e2df9e80bb8bf0875c8b427044a697
firebase_messaging: 90cf2b62f5058120ab19f29ceff1503ba0f51ba7
FirebaseCore: 2e86a4ea1684d4381707069e4a6d89ac808e901e
FirebaseCoreInternal: 6ab6a02c94446c026d2cf35cf5383842ebaa4992
FirebaseInstallations: eb29ccbf64eaedf86fd5b2ccc7fabde567660b52
FirebaseMessaging: 40017d7bc8457ee295b0f41d480a80fdabc9994e
Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
flutter_appauth: 53aecd8e5a88b27c0457bf06aa755d3b43625e85
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
PODFILE CHECKSUM: ce2a4dd764e1c7aeed6a7cdc5e61d092b6dc6d32
COCOAPODS: 1.16.2
+135 -5
View File
@@ -10,9 +10,11 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3D192DCCA3F7D857428950DE /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
93B4DE8061F359FC6A549D30 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
@@ -42,13 +44,16 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
017F0AB48EAAC1F19FC83B2F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = "<group>"; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
764D0C90813B439DD3167437 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = "<group>"; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = "<group>"; };
@@ -59,14 +64,28 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
FFAF78D225577C3406E1EDAE /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
1605CC1007E5A2E35A7817B4 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
93B4DE8061F359FC6A549D30 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */,
3D192DCCA3F7D857428950DE /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -81,6 +100,15 @@
path = RunnerTests;
sourceTree = "<group>";
};
8185E7C5804F08FC016736C9 /* Frameworks */ = {
isa = PBXGroup;
children = (
A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */,
CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
9740EEB11CF90186004384FC /* Flutter */ = {
isa = PBXGroup;
children = (
@@ -100,6 +128,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
CF03A8A68A90DC50946874FE /* Pods */,
8185E7C5804F08FC016736C9 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -128,6 +158,20 @@
path = Runner;
sourceTree = "<group>";
};
CF03A8A68A90DC50946874FE /* Pods */ = {
isa = PBXGroup;
children = (
FFAF78D225577C3406E1EDAE /* Pods-Runner.debug.xcconfig */,
017F0AB48EAAC1F19FC83B2F /* Pods-Runner.release.xcconfig */,
764D0C90813B439DD3167437 /* Pods-Runner.profile.xcconfig */,
B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */,
1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */,
C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -135,8 +179,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
C361AC12B26B49124E1928F4 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
1605CC1007E5A2E35A7817B4 /* Frameworks */,
);
buildRules = (
);
@@ -152,12 +198,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
F07428379A097C2D2DD2608F /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
A45E06FAFFF783A7CD54029D /* [CP] Embed Pods Frameworks */,
78AFF89006F9FC9C5E4B93E9 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -201,7 +250,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@@ -251,6 +300,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
78AFF89006F9FC9C5E4B93E9 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -266,6 +332,67 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
A45E06FAFFF783A7CD54029D /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
C361AC12B26B49124E1928F4 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
F07428379A097C2D2DD2608F /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -360,7 +487,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -392,6 +519,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = B35D44954EE6D9B854D37705 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -409,6 +537,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 1DECA71953B8676083696D0C /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -424,6 +553,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = C7F4C3F2FA5C38583A7660AC /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -486,7 +616,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -537,7 +667,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 13.0;
IPHONEOS_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
SUPPORTED_PLATFORMS = iphoneos;
@@ -627,7 +757,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
+3
View File
@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>
+11
View File
@@ -16,6 +16,17 @@
<string>6.0</string>
<key>CFBundleName</key>
<string>aioa_mobile</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleTypeRole</key>
<string>Editor</string>
<key>CFBundleURLSchemes</key>
<array>
<string>aioa</string>
</array>
</dict>
</array>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
+16 -2
View File
@@ -1,12 +1,26 @@
import 'package:aioa_mobile/app/router.dart';
import 'package:aioa_mobile/app/theme.dart';
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/auth/login_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:aioa_mobile/core/notifications/push_registration_controller.dart';
class AioaApp extends StatelessWidget {
class AioaApp extends ConsumerWidget {
const AioaApp({super.key});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authSessionProvider);
if (auth.isLoading) {
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAioaTheme(),
home: const Scaffold(body: Center(child: CircularProgressIndicator())),
);
}
if (auth.value == null) return const LoginPage();
ref.watch(pushRegistrationProvider);
return MaterialApp.router(
title: 'AIOA',
debugShowCheckedModeBanner: false,
+8
View File
@@ -2,6 +2,8 @@ import 'package:aioa_mobile/app/shell.dart';
import 'package:aioa_mobile/features/assistant/presentation/assistant_page.dart';
import 'package:aioa_mobile/features/form/presentation/leave_form_page.dart';
import 'package:aioa_mobile/features/profile/presentation/profile_page.dart';
import 'package:aioa_mobile/features/requests/presentation/leave_request_detail_page.dart';
import 'package:aioa_mobile/features/requests/presentation/leave_request_list_page.dart';
import 'package:aioa_mobile/features/tasks/presentation/tasks_page.dart';
import 'package:aioa_mobile/features/workspace/presentation/workspace_page.dart';
import 'package:flutter/material.dart';
@@ -27,5 +29,11 @@ final appRouter = GoRouter(
child: const LeaveFormPage(),
),
),
GoRoute(path: '/leave', builder: (_, _) => const LeaveRequestListPage()),
GoRoute(
path: '/leave/:id',
builder: (_, state) =>
LeaveRequestDetailPage(id: state.pathParameters['id']!),
),
],
);
+17
View File
@@ -0,0 +1,17 @@
class AuthSession {
const AuthSession({
required this.accessToken,
required this.refreshToken,
required this.idToken,
required this.expiresAt,
});
final String accessToken;
final String? refreshToken;
final String? idToken;
final DateTime expiresAt;
bool get needsRefresh => expiresAt.isBefore(
DateTime.now().toUtc().add(const Duration(minutes: 1)),
);
}
@@ -0,0 +1,167 @@
import 'package:aioa_mobile/core/auth/auth_session.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get oidcIssuer => RuntimeConfig.oidcIssuer;
const oidcClientId = 'aioa-mobile';
const oidcRedirectUrl = 'aioa://oauth/callback';
const oidcLogoutRedirectUrl = 'aioa://oauth/logout';
final authSessionProvider =
AsyncNotifierProvider<AuthSessionController, AuthSession?>(
AuthSessionController.new,
);
class AuthSessionController extends AsyncNotifier<AuthSession?> {
static const _storage = FlutterSecureStorage();
static const _appAuth = FlutterAppAuth();
@override
Future<AuthSession?> build() => _restore();
Future<void> login() async {
state = const AsyncLoading();
state = await AsyncValue.guard(() async {
final result = await _appAuth.authorizeAndExchangeCode(
AuthorizationTokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
promptValues: const ['login'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(result);
});
}
Future<void> logout() async {
final current = state.value;
try {
if (current?.idToken != null) {
await _appAuth.endSession(
EndSessionRequest(
idTokenHint: current!.idToken,
postLogoutRedirectUrl: oidcLogoutRedirectUrl,
issuer: oidcIssuer,
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
}
} finally {
await _clear();
state = const AsyncData(null);
}
}
Future<void> invalidateDeviceSession() async {
await _clear();
state = const AsyncData(null);
}
Future<String?> validAccessToken() async {
final current = state.value;
if (current == null) return null;
if (!current.needsRefresh) return current.accessToken;
final refreshToken = current.refreshToken;
if (refreshToken == null) {
await _clear();
state = const AsyncData(null);
return null;
}
try {
final refreshed = await _refresh(current);
state = AsyncData(refreshed);
return refreshed.accessToken;
} catch (_) {
await _clear();
state = const AsyncData(null);
return null;
}
}
Future<AuthSession?> _restore() async {
final values = await Future.wait([
_storage.read(key: 'access_token'),
_storage.read(key: 'refresh_token'),
_storage.read(key: 'id_token'),
_storage.read(key: 'expires_at'),
]);
final accessToken = values[0];
final expiresAt = DateTime.tryParse(values[3] ?? '');
if (accessToken == null || expiresAt == null) return null;
final session = AuthSession(
accessToken: accessToken,
refreshToken: values[1],
idToken: values[2],
expiresAt: expiresAt,
);
if (session.needsRefresh) {
if (session.refreshToken == null) {
await _clear();
return null;
}
try {
return await _refresh(session);
} catch (_) {
await _clear();
return null;
}
}
return session;
}
Future<AuthSession> _refresh(AuthSession current) async {
final response = await _appAuth.token(
TokenRequest(
oidcClientId,
oidcRedirectUrl,
issuer: oidcIssuer,
refreshToken: current.refreshToken,
scopes: const ['openid', 'profile', 'email', 'offline_access'],
allowInsecureConnections: oidcIssuer.startsWith('http://'),
),
);
return _storeResponse(response, fallback: current);
}
Future<AuthSession> _storeResponse(
TokenResponse response, {
AuthSession? fallback,
}) async {
final accessToken = response.accessToken ?? fallback?.accessToken;
final expiresAt =
response.accessTokenExpirationDateTime ?? fallback?.expiresAt;
if (accessToken == null || expiresAt == null) {
throw StateError('OIDC token response is incomplete');
}
final session = AuthSession(
accessToken: accessToken,
refreshToken: response.refreshToken ?? fallback?.refreshToken,
idToken: response.idToken ?? fallback?.idToken,
expiresAt: expiresAt.toUtc(),
);
await Future.wait([
_storage.write(key: 'access_token', value: session.accessToken),
_storage.write(key: 'refresh_token', value: session.refreshToken),
_storage.write(key: 'id_token', value: session.idToken),
_storage.write(
key: 'expires_at',
value: session.expiresAt.toIso8601String(),
),
]);
return session;
}
Future<void> _clear() async {
await Future.wait([
_storage.delete(key: 'access_token'),
_storage.delete(key: 'refresh_token'),
_storage.delete(key: 'id_token'),
_storage.delete(key: 'expires_at'),
]);
}
}
@@ -0,0 +1,148 @@
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import 'dart:io';
import 'dart:math';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
String get apiBaseUrl => RuntimeConfig.apiBaseUrl;
final authenticatedHttpClientProvider = Provider<http.Client>((ref) {
final inner = http.Client();
return AuthenticatedHttpClient(
inner: inner,
apiOrigin: Uri.parse(apiBaseUrl).origin,
tokenProvider: ref.read(authSessionProvider.notifier).validAccessToken,
deviceRegistration: ref.watch(deviceRegistrationProvider),
);
});
final deviceRegistrationProvider = Provider<DeviceRegistration>((ref) {
return DeviceRegistration(
inner: http.Client(),
baseUrl: apiBaseUrl,
onRevoked: ref.read(authSessionProvider.notifier).invalidateDeviceSession,
);
});
class AuthenticatedHttpClient extends http.BaseClient {
AuthenticatedHttpClient({
required this.inner,
required this.apiOrigin,
required this.tokenProvider,
this.deviceRegistration,
});
final http.Client inner;
final String apiOrigin;
final Future<String?> Function() tokenProvider;
final DeviceRegistration? deviceRegistration;
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
if (request.url.origin == apiOrigin &&
!request.headers.containsKey('Authorization')) {
final token = await tokenProvider();
if (token != null) {
request.headers['Authorization'] = 'Bearer $token';
final registration = deviceRegistration;
if (registration != null) {
request.headers['X-AIOA-Device-Id'] = await registration.ensure(
token,
);
}
}
}
final response = await inner.send(request);
if (response.headers['x-aioa-auth-error'] == 'DEVICE_REVOKED') {
await deviceRegistration?.revoked();
}
return response;
}
@override
void close() => inner.close();
}
class DeviceRegistration {
DeviceRegistration({
required this.inner,
required this.baseUrl,
required this.onRevoked,
});
static const _storage = FlutterSecureStorage();
final http.Client inner;
final String baseUrl;
final Future<void> Function() onRevoked;
String? _registeredToken;
String? _deviceId;
Future<String> ensure(String token) async {
final id = _deviceId ??= await _loadOrCreateId();
if (_registeredToken == token) return id;
final platform = Platform.isIOS
? 'IOS'
: Platform.isAndroid
? 'ANDROID'
: 'OTHER';
final response = await inner.post(
Uri.parse('$baseUrl/devices/register'),
headers: {
'Authorization': 'Bearer $token',
'Content-Type': 'application/json',
},
body: jsonEncode({
'id': id,
'name': Platform.localHostname.isEmpty
? '$platform 设备'
: Platform.localHostname,
'platform': platform,
}),
);
if (response.statusCode == 401 || response.statusCode == 403) {
await revoked();
throw const DeviceRevokedException();
}
if (response.statusCode < 200 || response.statusCode >= 300) {
throw DeviceRegistrationException(response.statusCode);
}
_registeredToken = token;
return id;
}
Future<void> revoked() async {
_registeredToken = null;
await onRevoked();
}
Future<String> currentId() async => _deviceId ??= await _loadOrCreateId();
Future<String> _loadOrCreateId() async {
final existing = await _storage.read(key: 'device_id');
if (existing != null) return existing;
final random = Random.secure();
final bytes = List<int>.generate(16, (_) => random.nextInt(256));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
String hex(int start, int end) => bytes
.sublist(start, end)
.map((value) => value.toRadixString(16).padLeft(2, '0'))
.join();
final id =
'${hex(0, 4)}-${hex(4, 6)}-${hex(6, 8)}-${hex(8, 10)}-${hex(10, 16)}';
await _storage.write(key: 'device_id', value: id);
return id;
}
}
class DeviceRevokedException implements Exception {
const DeviceRevokedException();
}
class DeviceRegistrationException implements Exception {
const DeviceRegistrationException(this.statusCode);
final int statusCode;
}
+74
View File
@@ -0,0 +1,74 @@
import 'package:aioa_mobile/app/theme.dart';
import 'package:aioa_mobile/core/auth/auth_session_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class LoginPage extends ConsumerWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authSessionProvider);
return MaterialApp(
debugShowCheckedModeBanner: false,
theme: buildAioaTheme(),
home: Scaffold(
body: SafeArea(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(28),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const CircleAvatar(
radius: 36,
child: Icon(Icons.apartment_rounded, size: 38),
),
const SizedBox(height: 20),
Text(
'AIOA',
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.headlineMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 8),
const Text('使用企业账号安全登录', textAlign: TextAlign.center),
if (auth.hasError) ...[
const SizedBox(height: 16),
Text(
'登录失败:${auth.error}',
textAlign: TextAlign.center,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
),
),
],
const SizedBox(height: 28),
FilledButton.icon(
onPressed: auth.isLoading
? null
: ref.read(authSessionProvider.notifier).login,
icon: auth.isLoading
? const SizedBox.square(
dimension: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.login),
label: const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Text('Keycloak 登录'),
),
),
],
),
),
),
),
),
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:flutter/foundation.dart';
class RuntimeConfig {
static const _configuredApiBaseUrl = String.fromEnvironment(
'AIOA_API_BASE_URL',
);
static const _configuredOidcIssuer = String.fromEnvironment(
'AIOA_OIDC_ISSUER',
);
static String get apiBaseUrl => _configuredApiBaseUrl.isNotEmpty
? _configuredApiBaseUrl
: '${_localOrigin(8080)}/api/v1';
static String get oidcIssuer => _configuredOidcIssuer.isNotEmpty
? _configuredOidcIssuer
: 'http://localhost:8081/realms/aioa';
static String _localOrigin(int port) {
final host = defaultTargetPlatform == TargetPlatform.android
? '10.0.2.2'
: '127.0.0.1';
return 'http://$host:$port';
}
}
+85 -1
View File
@@ -10,6 +10,17 @@ class DynamicFormDefinition {
final JsonFormSchema dataSchema;
final FormUiSchema uiSchema;
factory DynamicFormDefinition.fromJson(Map<String, Object?> json) {
return DynamicFormDefinition(
dataSchema: JsonFormSchema.fromJson(
Map<String, Object?>.from(json['dataSchema']! as Map),
),
uiSchema: FormUiSchema.fromJson(
Map<String, Object?>.from(json['uiSchema']! as Map),
),
);
}
}
class JsonFormSchema {
@@ -24,6 +35,23 @@ class JsonFormSchema {
final String title;
final Map<String, JsonFieldSchema> properties;
final Set<String> required;
factory JsonFormSchema.fromJson(Map<String, Object?> json) {
final properties = Map<String, Object?>.from(json['properties']! as Map);
return JsonFormSchema(
id: json[r'$id']! as String,
title: json['title']! as String,
properties: properties.map(
(key, value) => MapEntry(
key,
JsonFieldSchema.fromJson(Map<String, Object?>.from(value as Map)),
),
),
required: ((json['required'] as List?) ?? const [])
.cast<String>()
.toSet(),
);
}
}
class JsonFieldSchema {
@@ -40,6 +68,16 @@ class JsonFieldSchema {
final List<String> enumValues;
final int? minLength;
final int? maxLength;
factory JsonFieldSchema.fromJson(Map<String, Object?> json) {
return JsonFieldSchema(
type: JsonValueType.values.byName(json['type']! as String),
format: json['format'] as String?,
enumValues: ((json['enum'] as List?) ?? const []).cast<String>(),
minLength: json['minLength'] as int?,
maxLength: json['maxLength'] as int?,
);
}
}
class FormUiSchema {
@@ -47,6 +85,19 @@ class FormUiSchema {
final String description;
final List<FormSectionSchema> sections;
factory FormUiSchema.fromJson(Map<String, Object?> json) {
return FormUiSchema(
description: json['description']! as String,
sections: (json['sections']! as List)
.map(
(value) => FormSectionSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormSectionSchema {
@@ -54,6 +105,19 @@ class FormSectionSchema {
final String title;
final List<FormControlSchema> controls;
factory FormSectionSchema.fromJson(Map<String, Object?> json) {
return FormSectionSchema(
title: json['title']! as String,
controls: (json['controls']! as List)
.map(
(value) => FormControlSchema.fromJson(
Map<String, Object?>.from(value as Map),
),
)
.toList(),
);
}
}
class FormControlSchema {
@@ -72,21 +136,41 @@ class FormControlSchema {
final String? placeholder;
final String? helperText;
final Map<String, String> optionLabels;
factory FormControlSchema.fromJson(Map<String, Object?> json) {
return FormControlSchema(
field: json['field']! as String,
label: json['label']! as String,
control: FormControlType.values.byName(json['control']! as String),
placeholder: json['placeholder'] as String?,
helperText: json['helperText'] as String?,
optionLabels: ((json['optionLabels'] as Map?) ?? const {})
.cast<String, String>(),
);
}
}
class DynamicFormState {
const DynamicFormState({this.values = const {}, this.errors = const {}});
const DynamicFormState({
this.values = const {},
this.errors = const {},
this.restoredAt,
});
final Map<String, Object?> values;
final Map<String, String> errors;
final DateTime? restoredAt;
DynamicFormState copyWith({
Map<String, Object?>? values,
Map<String, String>? errors,
DateTime? restoredAt,
bool clearRestoredAt = false,
}) {
return DynamicFormState(
values: values ?? this.values,
errors: errors ?? this.errors,
restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt,
);
}
}
@@ -0,0 +1,53 @@
import 'dart:convert';
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:http/http.dart' as http;
final pushRegistrationProvider =
AsyncNotifierProvider<PushRegistrationController, bool>(
PushRegistrationController.new,
);
class PushRegistrationController extends AsyncNotifier<bool> {
@override
Future<bool> build() async {
try {
if (Firebase.apps.isEmpty) await Firebase.initializeApp();
final messaging = FirebaseMessaging.instance;
final settings = await messaging.requestPermission(
alert: true,
badge: true,
sound: true,
);
if (settings.authorizationStatus == AuthorizationStatus.denied) {
return false;
}
final token = await messaging.getToken();
if (token == null) return false;
await _upload(token);
messaging.onTokenRefresh.listen(_upload);
return true;
} catch (_) {
// 未提供 Firebase 平台配置时保留站内通知,应用仍可正常启动。
return false;
}
}
Future<void> _upload(String token) async {
final id = await ref.read(deviceRegistrationProvider).currentId();
final response = await ref
.read(authenticatedHttpClientProvider)
.put(
Uri.parse('${RuntimeConfig.apiBaseUrl}/devices/$id/push-token'),
headers: const {'Content-Type': 'application/json'},
body: jsonEncode({'token': token}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw http.ClientException('Push token registration failed');
}
}
}
@@ -0,0 +1,30 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveProgressRepositoryProvider = Provider(
(ref) => LeaveProgressRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveProgressProvider =
AsyncNotifierProvider<LeaveProgressController, ProgressAnswer?>(
LeaveProgressController.new,
);
class LeaveProgressController extends AsyncNotifier<ProgressAnswer?> {
String _question = '';
@override
Future<ProgressAnswer?> build() async => null;
Future<void> ask(String question, {String? selectedRequestId}) async {
if (selectedRequestId == null) _question = question.trim();
state = const AsyncLoading();
state = await AsyncValue.guard(
() => ref
.read(leaveProgressRepositoryProvider)
.ask(_question, selectedRequestId: selectedRequestId),
);
}
}
@@ -0,0 +1,88 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class ProgressCandidate {
const ProgressCandidate({
required this.id,
required this.type,
required this.status,
required this.startsAt,
required this.endsAt,
});
final String id, type, status;
final DateTime startsAt, endsAt;
factory ProgressCandidate.fromJson(Map<String, Object?> j) =>
ProgressCandidate(
id: j['id']! as String,
type: j['type']! as String,
status: j['status']! as String,
startsAt: DateTime.parse(j['startsAt']! as String),
endsAt: DateTime.parse(j['endsAt']! as String),
);
}
class ProgressAnswer {
const ProgressAnswer({
required this.requiresSelection,
required this.candidates,
this.answer,
this.request,
this.activeTasks = const [],
this.completedTasks = const [],
this.processEnded = false,
});
final bool requiresSelection, processEnded;
final List<ProgressCandidate> candidates;
final String? answer;
final ProgressCandidate? request;
final List<String> activeTasks, completedTasks;
factory ProgressAnswer.fromJson(Map<String, Object?> j) {
final p = j['progress'] as Map<String, Object?>?;
return ProgressAnswer(
requiresSelection: j['requiresSelection']! as bool,
candidates: ((j['candidates'] as List?) ?? const [])
.map(
(e) =>
ProgressCandidate.fromJson(Map<String, Object?>.from(e as Map)),
)
.toList(),
answer: j['answer'] as String?,
request: j['request'] == null
? null
: ProgressCandidate.fromJson(
Map<String, Object?>.from(j['request']! as Map),
),
activeTasks: ((p?['activeTaskNames'] as List?) ?? const [])
.cast<String>(),
completedTasks: ((p?['completedTaskNames'] as List?) ?? const [])
.cast<String>(),
processEnded: p?['processEnded'] as bool? ?? false,
);
}
}
class LeaveProgressRepository {
LeaveProgressRepository({required this.client, required this.baseUrl});
final http.Client client;
final String baseUrl;
Future<ProgressAnswer> ask(String text, {String? selectedRequestId}) async {
final response = await client.post(
Uri.parse('$baseUrl/ai/leave-progress-answers'),
headers: const {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: jsonEncode({
'text': text,
'timezone': 'Asia/Shanghai',
'selectedRequestId': ?selectedRequestId,
}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw Exception('查询失败(${response.statusCode}');
}
return ProgressAnswer.fromJson(
jsonDecode(response.body) as Map<String, Object?>,
);
}
}
@@ -1,9 +1,147 @@
import 'package:aioa_mobile/features/assistant/application/leave_progress_controller.dart';
import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
class AssistantPage extends StatelessWidget {
class AssistantPage extends ConsumerStatefulWidget {
const AssistantPage({super.key});
@override
ConsumerState<AssistantPage> createState() => _AssistantPageState();
}
class _AssistantPageState extends ConsumerState<AssistantPage> {
final _controller = TextEditingController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) =>
const Center(child: Text('AI 助手将在下一阶段接入'));
Widget build(BuildContext context) {
final result = ref.watch(leaveProgressProvider);
return Scaffold(
appBar: AppBar(title: const Text('AI 流程助手')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
const Text('询问本人请假流程进度,AI 只读查询,不会执行审批或修改申请。'),
const SizedBox(height: 12),
TextField(
controller: _controller,
minLines: 2,
maxLines: 4,
decoration: const InputDecoration(
border: OutlineInputBorder(),
hintText: '例如:我最近提交的年假审批到哪一步了?',
),
),
const SizedBox(height: 10),
FilledButton.icon(
onPressed: result.isLoading
? null
: () {
if (_controller.text.trim().isNotEmpty) {
ref
.read(leaveProgressProvider.notifier)
.ask(_controller.text);
}
},
icon: const Icon(Icons.auto_awesome),
label: const Text('查询进度'),
),
const SizedBox(height: 16),
result.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('查询失败:$e'),
),
),
data: (data) => data == null
? const SizedBox.shrink()
: _Result(
data: data,
onSelect: (id) => ref
.read(leaveProgressProvider.notifier)
.ask('', selectedRequestId: id),
),
),
],
),
);
}
}
class _Result extends StatelessWidget {
const _Result({required this.data, required this.onSelect});
final ProgressAnswer data;
final ValueChanged<String> onSelect;
@override
Widget build(BuildContext context) {
if (data.requiresSelection) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('找到多条可能的申请,请选择:'),
...data.candidates.map(
(c) => Card(
child: ListTile(
onTap: () => onSelect(c.id),
title: Text('${_type(c.type)} · ${_status(c.status)}'),
subtitle: Text(
'${DateFormat('MM-dd HH:mm').format(c.startsAt.toLocal())}${DateFormat('MM-dd HH:mm').format(c.endsAt.toLocal())}',
),
trailing: const Icon(Icons.chevron_right),
),
),
),
],
);
}
final request = data.request!;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${_type(request.type)} · ${_status(request.status)}',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 10),
Text(data.answer ?? ''),
if (data.activeTasks.isNotEmpty) ...[
const SizedBox(height: 12),
Text('当前节点:${data.activeTasks.join('')}'),
],
if (data.completedTasks.isNotEmpty)
Text('已完成:${data.completedTasks.join('')}'),
const SizedBox(height: 12),
OutlinedButton.icon(
onPressed: () => context.push('/leave/${request.id}'),
icon: const Icon(Icons.open_in_new),
label: const Text('查看申请详情'),
),
],
),
),
);
}
static String _type(String v) =>
{'PERSONAL': '事假', 'SICK': '病假', 'ANNUAL': '年假'}[v] ?? v;
static String _status(String v) =>
{
'DRAFT': '草稿',
'PENDING': '审批中',
'APPROVED': '已通过',
'REJECTED': '已驳回',
'WITHDRAWN': '已撤回',
}[v] ??
v;
}
@@ -0,0 +1,12 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final aiLeaveSuggestionRepositoryProvider =
Provider<AiLeaveSuggestionRepository>(
(ref) => AiLeaveSuggestionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
@@ -0,0 +1,15 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/form_definition_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final formDefinitionRepositoryProvider = Provider<FormDefinitionRepository>(
(ref) => FormDefinitionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveFormDefinitionProvider = FutureProvider<LoadedFormDefinition>((ref) {
return ref.watch(formDefinitionRepositoryProvider).loadLeaveRequest();
});
@@ -0,0 +1,97 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'dart:typed_data';
import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveAttachmentRepositoryProvider = Provider<LeaveAttachmentRepository>(
(ref) => LeaveAttachmentRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
class LeaveAttachmentState {
const LeaveAttachmentState({
this.items = const [],
this.uploading = false,
this.progress = 0,
this.error,
});
final List<LeaveAttachmentItem> items;
final bool uploading;
final double progress;
final String? error;
LeaveAttachmentState copyWith({
List<LeaveAttachmentItem>? items,
bool? uploading,
double? progress,
String? error,
bool clearError = false,
}) => LeaveAttachmentState(
items: items ?? this.items,
uploading: uploading ?? this.uploading,
progress: progress ?? this.progress,
error: clearError ? null : error ?? this.error,
);
}
final leaveAttachmentProvider =
NotifierProvider.family<
LeaveAttachmentController,
LeaveAttachmentState,
String
>((leaveRequestId) => LeaveAttachmentController(leaveRequestId));
class LeaveAttachmentController extends Notifier<LeaveAttachmentState> {
LeaveAttachmentController(this._leaveRequestId);
final String _leaveRequestId;
@override
LeaveAttachmentState build() => const LeaveAttachmentState();
Future<void> load() async {
try {
final items = await ref
.read(leaveAttachmentRepositoryProvider)
.list(_leaveRequestId);
state = state.copyWith(items: items, clearError: true);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(error: error.message);
}
}
Future<void> upload({
required String fileName,
required String contentType,
required Uint8List bytes,
}) async {
if (state.uploading) return;
state = state.copyWith(uploading: true, progress: 0, clearError: true);
try {
final item = await ref
.read(leaveAttachmentRepositoryProvider)
.upload(
leaveRequestId: _leaveRequestId,
fileName: fileName,
contentType: contentType,
bytes: bytes,
onProgress: (progress) {
state = state.copyWith(progress: progress);
},
);
state = state.copyWith(
items: [...state.items, item],
uploading: false,
progress: 1,
clearError: true,
);
} on LeaveAttachmentException catch (error) {
state = state.copyWith(uploading: false, error: error.message);
}
}
}
@@ -1,21 +1,47 @@
import 'dart:async';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/core/forms/schema/form_validator.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveLocalDraftRepositoryProvider = Provider<LeaveLocalDraftRepository>(
(ref) => LeaveLocalDraftRepository(),
);
final leaveDraftProvider =
NotifierProvider<LeaveDraftController, DynamicFormState>(
LeaveDraftController.new,
);
class LeaveDraftController extends Notifier<DynamicFormState> {
late final LeaveLocalDraftRepository _repository;
@override
DynamicFormState build() => const DynamicFormState();
DynamicFormState build() {
_repository = ref.watch(leaveLocalDraftRepositoryProvider);
unawaited(_restore());
return const DynamicFormState();
}
Future<void> _restore() async {
final restored = await _repository.load();
if (restored == null || state.values.isNotEmpty) return;
state = DynamicFormState(
values: restored.values,
restoredAt: restored.savedAt,
);
}
void setValue(String field, Object? value) {
final values = {...state.values, field: value};
final errors = {...state.errors}..remove(field);
state = state.copyWith(values: values, errors: errors);
state = state.copyWith(
values: values,
errors: errors,
clearRestoredAt: true,
);
unawaited(_repository.save(values));
}
void applyAiSuggestion() {
@@ -36,13 +62,22 @@ class LeaveDraftController extends Notifier<DynamicFormState> {
'reason': '办理个人事务,已提前完成工作交接。',
},
);
unawaited(_repository.save(state.values));
}
bool validate() {
final errors = validateDynamicForm(
leaveFormDefinition.dataSchema,
state.values,
);
void applySuggestion(Map<String, Object?> suggestion) {
final values = {...state.values, ...suggestion};
state = DynamicFormState(values: values);
unawaited(_repository.save(values));
}
Future<void> clear() async {
await _repository.clear();
state = const DynamicFormState();
}
bool validate(JsonFormSchema schema) {
final errors = validateDynamicForm(schema, state.values);
final startsAt = DateTime.tryParse(
state.values['startsAt'] as String? ?? '',
);
@@ -0,0 +1,44 @@
import 'package:aioa_mobile/core/auth/authenticated_http_client.dart';
import 'package:aioa_mobile/core/config/runtime_config.dart';
import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final leaveDraftSubmissionRepositoryProvider =
Provider<LeaveDraftSubmissionRepository>(
(ref) => LeaveDraftSubmissionRepository(
client: ref.watch(authenticatedHttpClientProvider),
baseUrl: RuntimeConfig.apiBaseUrl,
),
);
final leaveSubmissionProvider =
NotifierProvider<LeaveSubmissionController, LeaveSubmissionState>(
LeaveSubmissionController.new,
);
class LeaveSubmissionState {
const LeaveSubmissionState({this.submitting = false, this.error});
final bool submitting;
final String? error;
}
class LeaveSubmissionController extends Notifier<LeaveSubmissionState> {
@override
LeaveSubmissionState build() => const LeaveSubmissionState();
Future<CreatedLeaveDraft?> submit(Map<String, Object?> values) async {
if (state.submitting) return null;
state = const LeaveSubmissionState(submitting: true);
try {
final created = await ref
.read(leaveDraftSubmissionRepositoryProvider)
.create(values);
state = const LeaveSubmissionState();
return created;
} on LeaveDraftSubmissionException catch (error) {
state = LeaveSubmissionState(error: error.message);
return null;
}
}
}
@@ -0,0 +1,79 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
class AiLeaveSuggestion {
const AiLeaveSuggestion({
required this.values,
required this.assumptions,
required this.needsClarification,
required this.model,
});
final Map<String, Object?> values;
final List<String> assumptions;
final List<String> needsClarification;
final String model;
}
class AiLeaveSuggestionException implements Exception {
const AiLeaveSuggestionException(this.message);
final String message;
@override
String toString() => message;
}
class AiLeaveSuggestionRepository {
AiLeaveSuggestionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<AiLeaveSuggestion> suggest(String text) async {
final response = await _client.post(
Uri.parse('$baseUrl/ai/leave-draft-suggestions'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
},
body: jsonEncode({'text': text.trim(), 'timezone': 'Asia/Shanghai'}),
);
if (response.statusCode < 200 || response.statusCode >= 300) {
String? message;
try {
message =
(jsonDecode(response.body) as Map<String, Object?>)['detail']
as String?;
} catch (_) {}
throw AiLeaveSuggestionException(
message ?? 'AI 建议生成失败(${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
if (json['requiresUserConfirmation'] != true) {
throw const AiLeaveSuggestionException('AI 响应缺少用户确认保护');
}
final suggestion = Map<String, Object?>.from(json['suggestion']! as Map);
return AiLeaveSuggestion(
values: {
for (final key in const ['type', 'startsAt', 'endsAt', 'reason'])
if (suggestion[key] != null) key: suggestion[key],
},
assumptions: ((suggestion['assumptions'] as List?) ?? const [])
.cast<String>(),
needsClarification:
((suggestion['needsClarification'] as List?) ?? const [])
.cast<String>(),
model: json['model']! as String,
);
}
}
@@ -0,0 +1,83 @@
import 'dart:convert';
import 'package:aioa_mobile/core/forms/schema/form_schema.dart';
import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
enum FormDefinitionSource { remote, cache, bundled }
class LoadedFormDefinition {
const LoadedFormDefinition({required this.definition, required this.source});
final DynamicFormDefinition definition;
final FormDefinitionSource source;
}
class FormDefinitionRepository {
FormDefinitionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const _cacheKey = 'form-definition.leave-request.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LoadedFormDefinition> loadLeaveRequest() async {
final preferences = await SharedPreferences.getInstance();
try {
final response = await _client
.get(
Uri.parse('$baseUrl/form-definitions/leave-request'),
headers: {
'Accept': 'application/json',
if (accessToken.isNotEmpty)
'Authorization': 'Bearer $accessToken',
},
)
.timeout(const Duration(seconds: 5));
if (response.statusCode != 200) {
throw http.ClientException(
'Form definition request failed: ${response.statusCode}',
);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
final definition = DynamicFormDefinition.fromJson(json);
try {
await preferences.setString(_cacheKey, response.body);
} catch (_) {
// A valid remote definition remains usable even if local persistence
// is temporarily unavailable.
}
return LoadedFormDefinition(
definition: definition,
source: FormDefinitionSource.remote,
);
} catch (_) {
final cached = preferences.getString(_cacheKey);
if (cached != null) {
try {
return LoadedFormDefinition(
definition: DynamicFormDefinition.fromJson(
jsonDecode(cached) as Map<String, Object?>,
),
source: FormDefinitionSource.cache,
);
} catch (_) {
await preferences.remove(_cacheKey);
}
}
return const LoadedFormDefinition(
definition: leaveFormDefinition,
source: FormDefinitionSource.bundled,
);
}
}
}
@@ -0,0 +1,147 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
class LeaveAttachmentItem {
const LeaveAttachmentItem({
required this.id,
required this.fileName,
required this.contentType,
required this.sizeBytes,
required this.status,
});
final String id;
final String fileName;
final String contentType;
final int sizeBytes;
final String status;
factory LeaveAttachmentItem.fromJson(Map<String, Object?> json) {
return LeaveAttachmentItem(
id: json['id']! as String,
fileName: json['fileName']! as String,
contentType: json['contentType']! as String,
sizeBytes: json['sizeBytes']! as int,
status: json['status']! as String,
);
}
}
class LeaveAttachmentException implements Exception {
const LeaveAttachmentException(this.message);
final String message;
@override
String toString() => message;
}
class LeaveAttachmentRepository {
LeaveAttachmentRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<LeaveAttachmentItem> upload({
required String leaveRequestId,
required String fileName,
required String contentType,
required Uint8List bytes,
required void Function(double progress) onProgress,
}) async {
if (bytes.isEmpty || bytes.length > 10 * 1024 * 1024) {
throw const LeaveAttachmentException('附件大小必须在 1 字节到 10 MB 之间');
}
final taskResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/upload-tasks',
),
headers: _jsonHeaders,
body: jsonEncode({
'fileName': fileName,
'contentType': contentType,
'sizeBytes': bytes.length,
}),
);
_requireSuccess(taskResponse, {201});
final task = jsonDecode(taskResponse.body) as Map<String, Object?>;
final attachment = Map<String, Object?>.from(task['attachment']! as Map);
final attachmentId = attachment['id']! as String;
final uploadRequest = http.StreamedRequest(
'PUT',
Uri.parse(task['uploadUrl']! as String),
);
uploadRequest.headers['Content-Type'] = contentType;
uploadRequest.contentLength = bytes.length;
final uploadResponseFuture = _client.send(uploadRequest);
const chunkSize = 64 * 1024;
var sent = 0;
for (var offset = 0; offset < bytes.length; offset += chunkSize) {
final end = (offset + chunkSize).clamp(0, bytes.length);
uploadRequest.sink.add(bytes.sublist(offset, end));
sent = end;
onProgress(sent / bytes.length);
}
await uploadRequest.sink.close();
final uploadResponse = await uploadResponseFuture;
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
throw LeaveAttachmentException('对象存储上传失败(${uploadResponse.statusCode}');
}
final completeResponse = await _client.post(
Uri.parse(
'$baseUrl/leave-requests/$leaveRequestId/attachments/$attachmentId/complete',
),
headers: _authHeaders,
);
_requireSuccess(completeResponse, {200});
onProgress(1);
return LeaveAttachmentItem.fromJson(
jsonDecode(completeResponse.body) as Map<String, Object?>,
);
}
Future<List<LeaveAttachmentItem>> list(String leaveRequestId) async {
final response = await _client.get(
Uri.parse('$baseUrl/leave-requests/$leaveRequestId/attachments'),
headers: _authHeaders,
);
_requireSuccess(response, {200});
return (jsonDecode(response.body) as List)
.map(
(item) => LeaveAttachmentItem.fromJson(
Map<String, Object?>.from(item as Map),
),
)
.toList();
}
Map<String, String> get _authHeaders => {
'Accept': 'application/json',
if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken',
};
Map<String, String> get _jsonHeaders => {
..._authHeaders,
'Content-Type': 'application/json',
};
void _requireSuccess(http.Response response, Set<int> expected) {
if (expected.contains(response.statusCode)) return;
String? message;
try {
final problem = jsonDecode(response.body) as Map<String, Object?>;
message = problem['detail'] as String?;
} catch (_) {}
throw LeaveAttachmentException(message ?? '附件请求失败(${response.statusCode}');
}
}
@@ -0,0 +1,144 @@
import 'dart:convert';
import 'dart:math';
import 'package:http/http.dart' as http;
import 'package:shared_preferences/shared_preferences.dart';
class CreatedLeaveDraft {
const CreatedLeaveDraft({
required this.id,
required this.status,
required this.version,
});
final String id;
final String status;
final int version;
}
class LeaveDraftSubmissionException implements Exception {
const LeaveDraftSubmissionException(this.message, {this.retryable = true});
final String message;
final bool retryable;
@override
String toString() => message;
}
class LeaveDraftSubmissionRepository {
LeaveDraftSubmissionRepository({
http.Client? client,
this.baseUrl = const String.fromEnvironment(
'AIOA_API_BASE_URL',
defaultValue: 'http://127.0.0.1:8080/api/v1',
),
this.accessToken = const String.fromEnvironment('AIOA_ACCESS_TOKEN'),
}) : _client = client ?? http.Client();
static const pendingStorageKey = 'leave-request.pending-create.v1';
final http.Client _client;
final String baseUrl;
final String accessToken;
Future<CreatedLeaveDraft> create(Map<String, Object?> values) async {
final requestBody = _requestBody(values);
final payload = jsonEncode(requestBody);
final preferences = await SharedPreferences.getInstance();
final pending = _readPending(preferences);
final idempotencyKey = pending?.payload == payload
? pending!.idempotencyKey
: _newIdempotencyKey();
await preferences.setString(
pendingStorageKey,
jsonEncode({'idempotencyKey': idempotencyKey, 'payload': payload}),
);
late http.Response response;
try {
response = await _client
.post(
Uri.parse('$baseUrl/leave-requests/drafts'),
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json',
'Authorization': 'Bearer $accessToken',
'Idempotency-Key': idempotencyKey,
},
body: payload,
)
.timeout(const Duration(seconds: 10));
} catch (_) {
throw const LeaveDraftSubmissionException('网络不可用,已保存请求,可安全重试');
}
if (response.statusCode != 200 && response.statusCode != 201) {
final message =
_problemMessage(response.body) ?? '创建草稿失败(${response.statusCode}';
final retryable =
response.statusCode >= 500 || response.statusCode == 401;
if (!retryable) await preferences.remove(pendingStorageKey);
throw LeaveDraftSubmissionException(message, retryable: retryable);
}
final json = jsonDecode(response.body) as Map<String, Object?>;
await preferences.remove(pendingStorageKey);
return CreatedLeaveDraft(
id: json['id']! as String,
status: json['status']! as String,
version: json['version']! as int,
);
}
Map<String, Object?> _requestBody(Map<String, Object?> values) => {
'type': values['type'],
'startsAt': values['startsAt'],
'endsAt': values['endsAt'],
'reason': values['reason'],
'version': 0,
};
_PendingSubmission? _readPending(SharedPreferences preferences) {
final encoded = preferences.getString(pendingStorageKey);
if (encoded == null) return null;
try {
final json = jsonDecode(encoded) as Map<String, Object?>;
return _PendingSubmission(
idempotencyKey: json['idempotencyKey']! as String,
payload: json['payload']! as String,
);
} catch (_) {
preferences.remove(pendingStorageKey);
return null;
}
}
String _newIdempotencyKey() {
final random = Random.secure();
final entropy = List.generate(
16,
(_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'),
).join();
return 'leave-${DateTime.now().microsecondsSinceEpoch}-$entropy';
}
String? _problemMessage(String body) {
try {
final problem = jsonDecode(body) as Map<String, Object?>;
return problem['detail'] as String? ?? problem['title'] as String?;
} catch (_) {
return null;
}
}
}
class _PendingSubmission {
const _PendingSubmission({
required this.idempotencyKey,
required this.payload,
});
final String idempotencyKey;
final String payload;
}

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