feat: complete leave approval MVP
This commit is contained in:
@@ -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=
|
||||
|
||||
@@ -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
@@ -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
|
||||
@@ -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。
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
.venv
|
||||
__pycache__
|
||||
.pytest_cache
|
||||
tests
|
||||
@@ -0,0 +1,9 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
COPY pyproject.toml README.md ./
|
||||
COPY app ./app
|
||||
RUN pip install --no-cache-dir .
|
||||
|
||||
EXPOSE 8000
|
||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
# AI Service
|
||||
|
||||
Python 3.11+、FastAPI 和 LangGraph。首期仅处理自然语言到请假草稿的结构化转换,以及流程进度查询规划;不拥有业务写权限。
|
||||
FastAPI 服务负责把自然语言转换为受约束的请假草稿建议。当前使用千问 OpenAI 兼容接口,但不拥有数据库、Flowable 或业务写权限。
|
||||
|
||||
```bash
|
||||
cd ai-service
|
||||
python3 -m venv .venv
|
||||
source .venv/bin/activate
|
||||
pip install -e '.[dev]'
|
||||
export QWEN_API_KEY='...'
|
||||
uvicorn app.main:app --reload --port 8000
|
||||
```
|
||||
|
||||
接口:`POST /v1/leave-drafts/suggest`。模型输出会经过 Pydantic 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""AIOA AI service."""
|
||||
@@ -0,0 +1,17 @@
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||
|
||||
qwen_api_key: str = ""
|
||||
qwen_base_url: str = "https://dashscope.aliyuncs.com/compatible-mode/v1"
|
||||
qwen_model: str = "qwen-plus"
|
||||
request_timeout_seconds: float = 20.0
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,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)
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,19 @@
|
||||
[project]
|
||||
name = "aioa-ai-service"
|
||||
version = "0.1.0"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"fastapi==0.116.1",
|
||||
"httpx==0.28.1",
|
||||
"pydantic-settings==2.10.1",
|
||||
"uvicorn[standard]==0.35.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"pytest==8.4.1",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
@@ -0,0 +1,57 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, get_progress_gateway
|
||||
from app.models import LeaveProgressAnswerRequest
|
||||
|
||||
|
||||
class FakeProgressGateway:
|
||||
async def answer_progress(self, request: LeaveProgressAnswerRequest) -> str:
|
||||
assert request.context.requestId == "leave-1"
|
||||
assert request.context.activeTaskNames == ["主管审批"]
|
||||
return "当前状态为审批中,正在等待主管审批。"
|
||||
|
||||
|
||||
def test_answers_only_from_authorized_structured_context() -> None:
|
||||
app.dependency_overrides[get_progress_gateway] = lambda: FakeProgressGateway()
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/v1/leave-progress/answer",
|
||||
json={
|
||||
"question": "我的请假到哪一步了?",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"context": {
|
||||
"requestId": "leave-1",
|
||||
"type": "ANNUAL",
|
||||
"status": "PENDING",
|
||||
"startsAt": "2026-07-20T01:00:00Z",
|
||||
"endsAt": "2026-07-20T09:00:00Z",
|
||||
"activeTaskNames": ["主管审批"],
|
||||
"completedTaskNames": [],
|
||||
"processEnded": False,
|
||||
"timelineEventTypes": ["LEAVE_REQUEST_SUBMITTED"],
|
||||
},
|
||||
},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json() == {"answer": "当前状态为审批中,正在等待主管审批。", "model": "qwen-plus"}
|
||||
assert "processVariables" not in response.text
|
||||
|
||||
|
||||
def test_rejects_unknown_context_fields() -> None:
|
||||
response = TestClient(app).post(
|
||||
"/v1/leave-progress/answer",
|
||||
json={
|
||||
"question": "进度?",
|
||||
"timezone": "Asia/Shanghai",
|
||||
"context": {
|
||||
"requestId": "leave-1", "type": "ANNUAL", "status": "PENDING",
|
||||
"startsAt": "2026-07-20T01:00:00Z", "endsAt": "2026-07-20T09:00:00Z",
|
||||
"activeTaskNames": [], "completedTaskNames": [], "processEnded": False,
|
||||
"timelineEventTypes": [], "processVariables": {"approverId": "secret"},
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 422
|
||||
@@ -0,0 +1,46 @@
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from app.main import app, get_gateway
|
||||
from app.models import LeaveDraftSuggestion, LeaveDraftSuggestionRequest, LeaveType
|
||||
|
||||
|
||||
class FakeGateway:
|
||||
async def suggest(self, request: LeaveDraftSuggestionRequest) -> LeaveDraftSuggestion:
|
||||
assert request.text == "明天下午请事假四小时"
|
||||
return LeaveDraftSuggestion(
|
||||
type=LeaveType.PERSONAL,
|
||||
startsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
|
||||
endsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
|
||||
reason="办理个人事务",
|
||||
assumptions=["下午按 13:30 开始计算"],
|
||||
)
|
||||
|
||||
|
||||
def test_returns_structured_suggestion_without_executing_business_action() -> None:
|
||||
app.dependency_overrides[get_gateway] = lambda: FakeGateway()
|
||||
try:
|
||||
response = TestClient(app).post(
|
||||
"/v1/leave-drafts/suggest",
|
||||
json={"text": "明天下午请事假四小时", "timezone": "Asia/Shanghai"},
|
||||
)
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["suggestion"]["type"] == "PERSONAL"
|
||||
assert body["requiresUserConfirmation"] is True
|
||||
assert "applicantId" not in body["suggestion"]
|
||||
|
||||
|
||||
def test_rejects_invalid_time_range_from_model() -> None:
|
||||
try:
|
||||
LeaveDraftSuggestion(
|
||||
startsAt=datetime.fromisoformat("2026-07-19T17:30:00+08:00"),
|
||||
endsAt=datetime.fromisoformat("2026-07-19T13:30:00+08:00"),
|
||||
)
|
||||
assert False, "validation should fail"
|
||||
except ValueError:
|
||||
pass
|
||||
@@ -28,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,
|
||||
)
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.all8ai.aioa.ai.infrastructure
|
||||
|
||||
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
|
||||
@Component
|
||||
class HttpAiLeaveDraftGateway(
|
||||
@Value("\${aioa.ai-service.url}") aiServiceUrl: String,
|
||||
) : AiLeaveDraftGateway {
|
||||
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
|
||||
|
||||
override fun suggest(text: String, timezone: String): SuggestedLeaveDraft = try {
|
||||
client.post()
|
||||
.uri("/v1/leave-drafts/suggest")
|
||||
.body(SuggestionRequest(text, timezone))
|
||||
.retrieve()
|
||||
.body(SuggestedLeaveDraft::class.java)
|
||||
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回建议")
|
||||
} catch (exception: ApiException) {
|
||||
throw exception
|
||||
} catch (exception: RestClientException) {
|
||||
throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用")
|
||||
}
|
||||
}
|
||||
|
||||
private data class SuggestionRequest(val text: String, val timezone: String)
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package com.all8ai.aioa.ai.infrastructure
|
||||
|
||||
import com.all8ai.aioa.ai.domain.*
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.client.RestClient
|
||||
import org.springframework.web.client.RestClientException
|
||||
|
||||
@Component
|
||||
class HttpAiLeaveProgressGateway(@Value("\${aioa.ai-service.url}") aiServiceUrl: String) : AiLeaveProgressGateway {
|
||||
private val client = RestClient.builder().baseUrl(aiServiceUrl).build()
|
||||
override fun answer(question: String, timezone: String, context: LeaveProgressContext): GeneratedProgressAnswer = try {
|
||||
client.post().uri("/v1/leave-progress/answer").body(ProgressRequest(question, timezone, context)).retrieve().body(GeneratedProgressAnswer::class.java)
|
||||
?: throw ApiException(HttpStatus.BAD_GATEWAY, "AI_RESPONSE_EMPTY", "AI 服务未返回回答")
|
||||
} catch (e: ApiException) { throw e } catch (_: RestClientException) { throw ApiException(HttpStatus.BAD_GATEWAY, "AI_SERVICE_UNAVAILABLE", "AI 服务暂时不可用") }
|
||||
}
|
||||
private data class ProgressRequest(val question: String, val timezone: String, val context: LeaveProgressContext)
|
||||
+20
-2
@@ -9,26 +9,32 @@ import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import com.all8ai.aioa.shared.web.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
|
||||
}
|
||||
|
||||
|
||||
+25
-3
@@ -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,
|
||||
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
package com.all8ai.aioa.attachment.api
|
||||
|
||||
import com.all8ai.aioa.attachment.application.CreateUploadCommand
|
||||
import com.all8ai.aioa.attachment.application.LeaveAttachmentService
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Positive
|
||||
import jakarta.validation.constraints.Size
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.DeleteMapping
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestBody
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.ResponseStatus
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/leave-requests/{leaveRequestId}/attachments")
|
||||
class LeaveAttachmentController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val service: LeaveAttachmentService,
|
||||
) {
|
||||
@PostMapping("/upload-tasks")
|
||||
@ResponseStatus(HttpStatus.CREATED)
|
||||
fun createUpload(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@Valid @RequestBody request: CreateAttachmentUploadRequest,
|
||||
): AttachmentUploadResponse {
|
||||
val upload = service.createUpload(currentUser(jwt), leaveRequestId, request.toCommand())
|
||||
return AttachmentUploadResponse(upload.attachment.toResponse(), upload.uploadUrl)
|
||||
}
|
||||
|
||||
@PostMapping("/{attachmentId}/complete")
|
||||
fun complete(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = service.complete(currentUser(jwt), leaveRequestId, attachmentId).toResponse()
|
||||
|
||||
@GetMapping
|
||||
fun list(@AuthenticationPrincipal jwt: Jwt, @PathVariable leaveRequestId: UUID) =
|
||||
service.list(currentUser(jwt), leaveRequestId).map(LeaveAttachment::toResponse)
|
||||
|
||||
@GetMapping("/{attachmentId}/download")
|
||||
fun download(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = AttachmentDownloadResponse(service.download(currentUser(jwt), leaveRequestId, attachmentId))
|
||||
|
||||
@DeleteMapping("/{attachmentId}")
|
||||
@ResponseStatus(HttpStatus.NO_CONTENT)
|
||||
fun delete(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable leaveRequestId: UUID,
|
||||
@PathVariable attachmentId: UUID,
|
||||
) = service.delete(currentUser(jwt), leaveRequestId, attachmentId)
|
||||
|
||||
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
}
|
||||
|
||||
data class CreateAttachmentUploadRequest(
|
||||
@field:NotBlank @field:Size(max = 255) val fileName: String,
|
||||
@field:NotBlank @field:Size(max = 128) val contentType: String,
|
||||
@field:Positive val sizeBytes: Long,
|
||||
) {
|
||||
fun toCommand() = CreateUploadCommand(fileName, contentType, sizeBytes)
|
||||
}
|
||||
|
||||
data class AttachmentUploadResponse(val attachment: LeaveAttachmentResponse, val uploadUrl: String)
|
||||
data class AttachmentDownloadResponse(val downloadUrl: String)
|
||||
data class LeaveAttachmentResponse(
|
||||
val id: UUID,
|
||||
val fileName: String,
|
||||
val contentType: String,
|
||||
val sizeBytes: Long,
|
||||
val status: String,
|
||||
val createdAt: Instant,
|
||||
val completedAt: Instant?,
|
||||
)
|
||||
|
||||
private fun LeaveAttachment.toResponse() = LeaveAttachmentResponse(
|
||||
id, fileName, contentType, sizeBytes, status.name, createdAt, completedAt,
|
||||
)
|
||||
+113
@@ -0,0 +1,113 @@
|
||||
package com.all8ai.aioa.attachment.application
|
||||
|
||||
import com.all8ai.aioa.approval.domain.LeaveRequestRepository
|
||||
import com.all8ai.aioa.approval.domain.LeaveStatus
|
||||
import com.all8ai.aioa.attachment.domain.AttachmentStatus
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
|
||||
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
|
||||
@Service
|
||||
class LeaveAttachmentService(
|
||||
private val leaveRequests: LeaveRequestRepository,
|
||||
private val attachments: LeaveAttachmentRepository,
|
||||
private val storage: ObjectStorageGateway,
|
||||
private val auditService: AuditService,
|
||||
) {
|
||||
fun createUpload(actor: CurrentUser, leaveRequestId: UUID, command: CreateUploadCommand): AttachmentUpload {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val fileName = command.fileName.trim().takeIf { it.isNotEmpty() && it.length <= 255 }
|
||||
?: invalid("ATTACHMENT_NAME_INVALID", "附件名称无效")
|
||||
if (command.sizeBytes !in 1..MAX_SIZE_BYTES) invalid("ATTACHMENT_SIZE_INVALID", "附件不能超过 10 MB")
|
||||
if (command.contentType !in ALLOWED_CONTENT_TYPES) invalid("ATTACHMENT_TYPE_INVALID", "不支持该附件类型")
|
||||
val id = UuidV7.generate()
|
||||
val safeName = fileName.replace(Regex("[^A-Za-z0-9._-]"), "_")
|
||||
val objectKey = "${actor.tenantId}/leave/$leaveRequestId/$id-$safeName"
|
||||
val attachment = attachments.create(
|
||||
LeaveAttachment(id, actor.tenantId, leaveRequestId, actor.id, fileName, command.contentType,
|
||||
command.sizeBytes, objectKey, AttachmentStatus.PENDING, Instant.now(), null),
|
||||
)
|
||||
val uploadUrl = storage.createUploadUrl(objectKey)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_UPLOAD_CREATE", "LEAVE_ATTACHMENT", id.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to command.sizeBytes, "contentType" to command.contentType))
|
||||
return AttachmentUpload(attachment, uploadUrl)
|
||||
}
|
||||
|
||||
fun complete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
if (attachment.status == AttachmentStatus.READY) return attachment
|
||||
val stored = storage.stat(attachment.objectKey)
|
||||
if (stored.sizeBytes != attachment.sizeBytes) invalid("ATTACHMENT_SIZE_MISMATCH", "附件大小校验失败")
|
||||
if (stored.contentType != null && stored.contentType != attachment.contentType) {
|
||||
invalid("ATTACHMENT_TYPE_MISMATCH", "附件类型校验失败")
|
||||
}
|
||||
val completed = attachments.markReady(actor.tenantId, attachmentId)
|
||||
?: throw ApiException(HttpStatus.CONFLICT, "ATTACHMENT_STATE_CONFLICT", "附件状态已变化")
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_COMPLETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId, "sizeBytes" to completed.sizeBytes))
|
||||
return completed
|
||||
}
|
||||
|
||||
fun list(actor: CurrentUser, leaveRequestId: UUID): List<LeaveAttachment> {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwn(actor, leaveRequestId)
|
||||
return attachments.list(actor.tenantId, leaveRequestId)
|
||||
}
|
||||
|
||||
fun download(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID): String {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwn(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
if (attachment.status != AttachmentStatus.READY) invalid("ATTACHMENT_NOT_READY", "附件尚未上传完成")
|
||||
val downloadUrl = storage.createDownloadUrl(attachment.objectKey)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DOWNLOAD", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId))
|
||||
return downloadUrl
|
||||
}
|
||||
|
||||
fun delete(actor: CurrentUser, leaveRequestId: UUID, attachmentId: UUID) {
|
||||
actor.requirePermission(ToolPermission.LEAVE_ATTACHMENT_MANAGE_OWN)
|
||||
requireOwnDraft(actor, leaveRequestId)
|
||||
val attachment = find(actor, leaveRequestId, attachmentId)
|
||||
storage.delete(attachment.objectKey)
|
||||
attachments.delete(actor.tenantId, attachmentId)
|
||||
auditService.recordSuccess(actor, "LEAVE_ATTACHMENT_DELETE", "LEAVE_ATTACHMENT", attachmentId.toString(), null,
|
||||
mapOf("leaveRequestId" to leaveRequestId))
|
||||
}
|
||||
|
||||
private fun find(actor: CurrentUser, requestId: UUID, attachmentId: UUID) =
|
||||
attachments.find(actor.tenantId, requestId, attachmentId)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "ATTACHMENT_NOT_FOUND", "附件不存在")
|
||||
|
||||
private fun requireOwn(actor: CurrentUser, id: UUID) = leaveRequests.findOwn(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "LEAVE_REQUEST_NOT_FOUND", "请假申请不存在")
|
||||
|
||||
private fun requireOwnDraft(actor: CurrentUser, id: UUID) {
|
||||
if (requireOwn(actor, id).status != LeaveStatus.DRAFT) {
|
||||
throw ApiException(HttpStatus.CONFLICT, "LEAVE_REQUEST_NOT_DRAFT", "只有草稿可以修改附件")
|
||||
}
|
||||
}
|
||||
|
||||
private fun invalid(code: String, message: String): Nothing = throw ApiException(HttpStatus.BAD_REQUEST, code, message)
|
||||
|
||||
companion object {
|
||||
const val MAX_SIZE_BYTES = 10L * 1024 * 1024
|
||||
val ALLOWED_CONTENT_TYPES = setOf("image/jpeg", "image/png", "application/pdf")
|
||||
}
|
||||
}
|
||||
|
||||
data class CreateUploadCommand(val fileName: String, val contentType: String, val sizeBytes: Long)
|
||||
data class AttachmentUpload(val attachment: LeaveAttachment, val uploadUrl: String)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.all8ai.aioa.attachment.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class AttachmentStatus { PENDING, READY }
|
||||
|
||||
data class LeaveAttachment(
|
||||
val id: UUID,
|
||||
val tenantId: UUID,
|
||||
val leaveRequestId: UUID,
|
||||
val uploaderId: UUID,
|
||||
val fileName: String,
|
||||
val contentType: String,
|
||||
val sizeBytes: Long,
|
||||
val objectKey: String,
|
||||
val status: AttachmentStatus,
|
||||
val createdAt: Instant,
|
||||
val completedAt: Instant?,
|
||||
)
|
||||
|
||||
interface LeaveAttachmentRepository {
|
||||
fun create(attachment: LeaveAttachment): LeaveAttachment
|
||||
fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment?
|
||||
fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment>
|
||||
fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment?
|
||||
fun delete(tenantId: UUID, attachmentId: UUID): Boolean
|
||||
}
|
||||
|
||||
interface ObjectStorageGateway {
|
||||
fun createUploadUrl(objectKey: String): String
|
||||
fun stat(objectKey: String): StoredObject
|
||||
fun createDownloadUrl(objectKey: String): String
|
||||
fun delete(objectKey: String)
|
||||
}
|
||||
|
||||
data class StoredObject(val sizeBytes: Long, val contentType: String?)
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
package com.all8ai.aioa.attachment.infrastructure
|
||||
|
||||
import com.all8ai.aioa.attachment.domain.AttachmentStatus
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachment
|
||||
import com.all8ai.aioa.attachment.domain.LeaveAttachmentRepository
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqLeaveAttachmentRepository(private val dsl: DSLContext) : LeaveAttachmentRepository {
|
||||
override fun create(attachment: LeaveAttachment): LeaveAttachment {
|
||||
dsl.execute(
|
||||
"""
|
||||
INSERT INTO business.leave_attachment (
|
||||
id, tenant_id, leave_request_id, uploader_id, file_name,
|
||||
content_type, size_bytes, object_key, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
attachment.id, attachment.tenantId, attachment.leaveRequestId, attachment.uploaderId,
|
||||
attachment.fileName, attachment.contentType, attachment.sizeBytes, attachment.objectKey,
|
||||
attachment.status.name,
|
||||
)
|
||||
return find(attachment.tenantId, attachment.leaveRequestId, attachment.id)!!
|
||||
}
|
||||
|
||||
override fun find(tenantId: UUID, leaveRequestId: UUID, attachmentId: UUID): LeaveAttachment? =
|
||||
dsl.fetchOne(
|
||||
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? AND id = ?",
|
||||
tenantId, leaveRequestId, attachmentId,
|
||||
)?.let(::map)
|
||||
|
||||
override fun list(tenantId: UUID, leaveRequestId: UUID): List<LeaveAttachment> = dsl.fetch(
|
||||
"SELECT * FROM business.leave_attachment WHERE tenant_id = ? AND leave_request_id = ? ORDER BY created_at, id",
|
||||
tenantId, leaveRequestId,
|
||||
).map(::map)
|
||||
|
||||
override fun markReady(tenantId: UUID, attachmentId: UUID): LeaveAttachment? = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE business.leave_attachment
|
||||
SET status = 'READY', completed_at = CURRENT_TIMESTAMP
|
||||
WHERE tenant_id = ? AND id = ? AND status = 'PENDING'
|
||||
RETURNING *
|
||||
""".trimIndent(),
|
||||
tenantId, attachmentId,
|
||||
)?.let(::map)
|
||||
|
||||
override fun delete(tenantId: UUID, attachmentId: UUID): Boolean =
|
||||
dsl.execute("DELETE FROM business.leave_attachment WHERE tenant_id = ? AND id = ?", tenantId, attachmentId) == 1
|
||||
|
||||
private fun map(record: Record) = LeaveAttachment(
|
||||
id = record.get("id", UUID::class.java)!!,
|
||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||
leaveRequestId = record.get("leave_request_id", UUID::class.java)!!,
|
||||
uploaderId = record.get("uploader_id", UUID::class.java)!!,
|
||||
fileName = record.get("file_name", String::class.java)!!,
|
||||
contentType = record.get("content_type", String::class.java)!!,
|
||||
sizeBytes = record.get("size_bytes", Long::class.java)!!,
|
||||
objectKey = record.get("object_key", String::class.java)!!,
|
||||
status = AttachmentStatus.valueOf(record.get("status", String::class.java)!!),
|
||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
completedAt = record.get("completed_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
)
|
||||
}
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
package com.all8ai.aioa.attachment.infrastructure
|
||||
|
||||
import com.all8ai.aioa.attachment.domain.ObjectStorageGateway
|
||||
import com.all8ai.aioa.attachment.domain.StoredObject
|
||||
import io.minio.BucketExistsArgs
|
||||
import io.minio.GetPresignedObjectUrlArgs
|
||||
import io.minio.MakeBucketArgs
|
||||
import io.minio.MinioClient
|
||||
import io.minio.RemoveObjectArgs
|
||||
import io.minio.StatObjectArgs
|
||||
import io.minio.http.Method
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
@Component
|
||||
class MinioObjectStorageGateway(
|
||||
@Value("\${aioa.object-storage.endpoint}") endpoint: String,
|
||||
@Value("\${aioa.object-storage.access-key}") accessKey: String,
|
||||
@Value("\${aioa.object-storage.secret-key}") secretKey: String,
|
||||
@Value("\${aioa.object-storage.bucket}") private val bucket: String,
|
||||
) : ObjectStorageGateway {
|
||||
private val client = MinioClient.builder().endpoint(endpoint).credentials(accessKey, secretKey).build()
|
||||
|
||||
override fun createUploadUrl(objectKey: String): String {
|
||||
ensureBucket()
|
||||
return client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder().method(Method.PUT).bucket(bucket).`object`(objectKey)
|
||||
.expiry(15, TimeUnit.MINUTES).build(),
|
||||
)
|
||||
}
|
||||
|
||||
override fun stat(objectKey: String): StoredObject = client.statObject(
|
||||
StatObjectArgs.builder().bucket(bucket).`object`(objectKey).build(),
|
||||
).let { StoredObject(it.size(), it.contentType()) }
|
||||
|
||||
override fun createDownloadUrl(objectKey: String): String = client.getPresignedObjectUrl(
|
||||
GetPresignedObjectUrlArgs.builder().method(Method.GET).bucket(bucket).`object`(objectKey)
|
||||
.expiry(5, TimeUnit.MINUTES).build(),
|
||||
)
|
||||
|
||||
override fun delete(objectKey: String) {
|
||||
client.removeObject(RemoveObjectArgs.builder().bucket(bucket).`object`(objectKey).build())
|
||||
}
|
||||
|
||||
private fun ensureBucket() {
|
||||
if (!client.bucketExists(BucketExistsArgs.builder().bucket(bucket).build())) {
|
||||
client.makeBucket(MakeBucketArgs.builder().bucket(bucket).build())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.all8ai.aioa.device.api
|
||||
|
||||
import com.all8ai.aioa.device.application.UserDeviceService
|
||||
import com.all8ai.aioa.device.domain.DevicePlatform
|
||||
import com.all8ai.aioa.device.domain.UserDevice
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import jakarta.validation.Valid
|
||||
import jakarta.validation.constraints.NotBlank
|
||||
import jakarta.validation.constraints.Size
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.*
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/devices")
|
||||
class UserDeviceController(private val currentUserService: CurrentUserService, private val service: UserDeviceService) {
|
||||
@PostMapping("/register")
|
||||
fun register(@AuthenticationPrincipal jwt: Jwt, @Valid @RequestBody request: RegisterDeviceRequest): UserDevice =
|
||||
service.register(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), request.id, request.name, request.platform, request.appVersion)
|
||||
|
||||
@GetMapping fun list(@AuthenticationPrincipal jwt: Jwt): List<UserDevice> =
|
||||
service.list(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")))
|
||||
|
||||
@DeleteMapping("/{id}") fun revoke(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID): UserDevice =
|
||||
service.revoke(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id)
|
||||
|
||||
@PutMapping("/{id}/push-token")
|
||||
fun updatePushToken(
|
||||
@AuthenticationPrincipal jwt: Jwt,
|
||||
@PathVariable id: UUID,
|
||||
@RequestHeader("X-AIOA-Device-Id") currentDeviceId: UUID,
|
||||
@Valid @RequestBody request: PushTokenRequest,
|
||||
) {
|
||||
if (id != currentDeviceId) throw com.all8ai.aioa.shared.web.ApiException(
|
||||
org.springframework.http.HttpStatus.FORBIDDEN, "DEVICE_MISMATCH", "只能更新当前设备的推送令牌",
|
||||
)
|
||||
service.updatePushToken(currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id")), id, request.token)
|
||||
}
|
||||
}
|
||||
|
||||
data class RegisterDeviceRequest(
|
||||
val id: UUID,
|
||||
@field:NotBlank @field:Size(max = 200) val name: String,
|
||||
val platform: DevicePlatform,
|
||||
@field:Size(max = 64) val appVersion: String? = null,
|
||||
)
|
||||
|
||||
data class PushTokenRequest(@field:Size(max = 4096) val token: String?)
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.all8ai.aioa.device.application
|
||||
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.device.domain.*
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.UUID
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class UserDeviceService(private val repository: UserDeviceRepository, private val auditService: AuditService) {
|
||||
fun register(actor: CurrentUser, id: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice {
|
||||
val normalizedName = name.trim().takeIf { it.isNotEmpty() && it.length <= 200 }
|
||||
?: throw ApiException(HttpStatus.BAD_REQUEST, "DEVICE_NAME_INVALID", "设备名称无效")
|
||||
val normalizedVersion = appVersion?.trim()?.takeIf { it.isNotEmpty() && it.length <= 64 }
|
||||
return repository.register(id, actor.tenantId, actor.id, normalizedName, platform, normalizedVersion)
|
||||
?: throw ApiException(HttpStatus.UNAUTHORIZED, "DEVICE_REVOKED", "该设备已被撤销,请联系管理员或使用其他设备")
|
||||
}
|
||||
|
||||
fun list(actor: CurrentUser): List<UserDevice> = repository.list(actor.tenantId, actor.id)
|
||||
|
||||
fun revoke(actor: CurrentUser, id: UUID): UserDevice {
|
||||
val device = repository.revoke(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在")
|
||||
auditService.recordSuccess(actor, "USER_DEVICE_REVOKED", "USER_DEVICE", id.toString(), null, mapOf("platform" to device.platform.name))
|
||||
return device
|
||||
}
|
||||
|
||||
@Transactional
|
||||
fun updatePushToken(actor: CurrentUser, id: UUID, token: String?) {
|
||||
val normalized = token?.trim()?.takeIf { it.isNotEmpty() && it.length <= 4096 }
|
||||
if (token != null && normalized == null) throw ApiException(HttpStatus.BAD_REQUEST, "PUSH_TOKEN_INVALID", "推送令牌无效")
|
||||
normalized?.let(repository::clearPushToken)
|
||||
if (!repository.updatePushToken(actor.tenantId, actor.id, id, normalized)) {
|
||||
throw ApiException(HttpStatus.NOT_FOUND, "DEVICE_NOT_FOUND", "设备不存在或已撤销")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.all8ai.aioa.device.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
enum class DeviceStatus { ACTIVE, REVOKED }
|
||||
enum class DevicePlatform { IOS, ANDROID, OTHER }
|
||||
|
||||
data class UserDevice(
|
||||
val id: UUID,
|
||||
val tenantId: UUID,
|
||||
val userId: UUID,
|
||||
val name: String,
|
||||
val platform: DevicePlatform,
|
||||
val appVersion: String?,
|
||||
val status: DeviceStatus,
|
||||
val registeredAt: Instant,
|
||||
val lastSeenAt: Instant,
|
||||
val revokedAt: Instant?,
|
||||
)
|
||||
|
||||
interface UserDeviceRepository {
|
||||
fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice?
|
||||
fun list(tenantId: UUID, userId: UUID): List<UserDevice>
|
||||
fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice?
|
||||
fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean
|
||||
fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean
|
||||
fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String>
|
||||
fun clearPushToken(token: String)
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.all8ai.aioa.device.infrastructure
|
||||
|
||||
import com.all8ai.aioa.device.domain.UserDeviceRepository
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.core.context.SecurityContextHolder
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.stereotype.Component
|
||||
import org.springframework.web.servlet.HandlerInterceptor
|
||||
import java.util.UUID
|
||||
|
||||
@Component
|
||||
class DeviceSessionInterceptor(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val devices: UserDeviceRepository,
|
||||
) : HandlerInterceptor {
|
||||
override fun preHandle(request: HttpServletRequest, response: HttpServletResponse, handler: Any): Boolean {
|
||||
if (request.method == "OPTIONS" || !request.requestURI.startsWith("/api/v1/") || request.requestURI == "/api/v1/devices/register") return true
|
||||
val jwt = SecurityContextHolder.getContext().authentication?.principal as? Jwt ?: return true
|
||||
val actor = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
val deviceId = request.getHeader(DEVICE_ID_HEADER)?.let { value ->
|
||||
runCatching { UUID.fromString(value) }.getOrNull()
|
||||
}
|
||||
if (deviceId != null && devices.touchActive(actor.tenantId, actor.id, deviceId)) return true
|
||||
response.status = HttpServletResponse.SC_UNAUTHORIZED
|
||||
response.contentType = MediaType.APPLICATION_PROBLEM_JSON_VALUE
|
||||
response.characterEncoding = Charsets.UTF_8.name()
|
||||
response.setHeader(AUTH_ERROR_HEADER, "DEVICE_REVOKED")
|
||||
response.writer.write("""{"title":"Unauthorized","status":401,"detail":"设备未注册或已被撤销","code":"DEVICE_REVOKED"}""")
|
||||
return false
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val DEVICE_ID_HEADER = "X-AIOA-Device-Id"
|
||||
const val AUTH_ERROR_HEADER = "X-AIOA-Auth-Error"
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package com.all8ai.aioa.device.infrastructure
|
||||
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.web.servlet.config.annotation.InterceptorRegistry
|
||||
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer
|
||||
|
||||
@Configuration
|
||||
class DeviceWebConfiguration(private val interceptor: DeviceSessionInterceptor) : WebMvcConfigurer {
|
||||
override fun addInterceptors(registry: InterceptorRegistry) {
|
||||
registry.addInterceptor(interceptor)
|
||||
}
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
package com.all8ai.aioa.device.infrastructure
|
||||
|
||||
import com.all8ai.aioa.device.domain.*
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqUserDeviceRepository(private val dsl: DSLContext) : UserDeviceRepository {
|
||||
override fun register(id: UUID, tenantId: UUID, userId: UUID, name: String, platform: DevicePlatform, appVersion: String?): UserDevice? =
|
||||
dsl.fetchOne(
|
||||
"""
|
||||
INSERT INTO identity.user_device (id, tenant_id, user_id, name, platform, app_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (tenant_id, user_id, id) DO UPDATE SET
|
||||
name = EXCLUDED.name, platform = EXCLUDED.platform,
|
||||
app_version = EXCLUDED.app_version, last_seen_at = CURRENT_TIMESTAMP
|
||||
WHERE user_device.status = 'ACTIVE'
|
||||
RETURNING *
|
||||
""".trimIndent(), id, tenantId, userId, name, platform.name, appVersion,
|
||||
)?.let(::map)
|
||||
|
||||
override fun list(tenantId: UUID, userId: UUID): List<UserDevice> = dsl.fetch(
|
||||
"SELECT * FROM identity.user_device WHERE tenant_id = ? AND user_id = ? ORDER BY last_seen_at DESC",
|
||||
tenantId, userId,
|
||||
).map(::map)
|
||||
|
||||
override fun revoke(tenantId: UUID, userId: UUID, id: UUID): UserDevice? = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE identity.user_device SET status = 'REVOKED', revoked_at = COALESCE(revoked_at, CURRENT_TIMESTAMP)
|
||||
WHERE tenant_id = ? AND user_id = ? AND id = ? RETURNING *
|
||||
""".trimIndent(), tenantId, userId, id,
|
||||
)?.let(::map)
|
||||
|
||||
override fun touchActive(tenantId: UUID, userId: UUID, id: UUID): Boolean = dsl.execute(
|
||||
"UPDATE identity.user_device SET last_seen_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
|
||||
tenantId, userId, id,
|
||||
) == 1
|
||||
|
||||
override fun updatePushToken(tenantId: UUID, userId: UUID, id: UUID, token: String?): Boolean = dsl.execute(
|
||||
"UPDATE identity.user_device SET push_token = ?, push_token_updated_at = CURRENT_TIMESTAMP WHERE tenant_id = ? AND user_id = ? AND id = ? AND status = 'ACTIVE'",
|
||||
token, tenantId, userId, id,
|
||||
) == 1
|
||||
|
||||
override fun listActivePushTokens(tenantId: UUID, userId: UUID): List<String> = dsl.fetch(
|
||||
"SELECT push_token FROM identity.user_device WHERE tenant_id = ? AND user_id = ? AND status = 'ACTIVE' AND push_token IS NOT NULL",
|
||||
tenantId, userId,
|
||||
).map { it.get("push_token", String::class.java)!! }
|
||||
|
||||
override fun clearPushToken(token: String) {
|
||||
dsl.execute("UPDATE identity.user_device SET push_token = NULL, push_token_updated_at = CURRENT_TIMESTAMP WHERE push_token = ?", token)
|
||||
}
|
||||
|
||||
private fun map(record: Record) = UserDevice(
|
||||
record.get("id", UUID::class.java)!!,
|
||||
record.get("tenant_id", UUID::class.java)!!,
|
||||
record.get("user_id", UUID::class.java)!!,
|
||||
record.get("name", String::class.java)!!,
|
||||
DevicePlatform.valueOf(record.get("platform", String::class.java)!!),
|
||||
record.get("app_version", String::class.java),
|
||||
DeviceStatus.valueOf(record.get("status", String::class.java)!!),
|
||||
record.get("registered_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
record.get("last_seen_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
record.get("revoked_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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(
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.all8ai.aioa.notification.api
|
||||
|
||||
import com.all8ai.aioa.identity.application.CurrentUserService
|
||||
import com.all8ai.aioa.notification.application.NotificationService
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal
|
||||
import org.springframework.security.oauth2.jwt.Jwt
|
||||
import org.springframework.web.bind.annotation.GetMapping
|
||||
import org.springframework.web.bind.annotation.PathVariable
|
||||
import org.springframework.web.bind.annotation.PostMapping
|
||||
import org.springframework.web.bind.annotation.RequestMapping
|
||||
import org.springframework.web.bind.annotation.RestController
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/notifications")
|
||||
class NotificationController(
|
||||
private val currentUserService: CurrentUserService,
|
||||
private val service: NotificationService,
|
||||
) {
|
||||
@GetMapping
|
||||
fun list(@AuthenticationPrincipal jwt: Jwt) = service.list(currentUser(jwt)).map(Notification::toResponse)
|
||||
|
||||
@GetMapping("/unread-count")
|
||||
fun unreadCount(@AuthenticationPrincipal jwt: Jwt) = UnreadCountResponse(service.unreadCount(currentUser(jwt)))
|
||||
|
||||
@PostMapping("/{id}/read")
|
||||
fun markRead(@AuthenticationPrincipal jwt: Jwt, @PathVariable id: UUID) =
|
||||
service.markRead(currentUser(jwt), id).toResponse()
|
||||
|
||||
private fun currentUser(jwt: Jwt) = currentUserService.get(jwt.subject, jwt.getClaimAsString("tenant_id"))
|
||||
}
|
||||
|
||||
data class UnreadCountResponse(val unreadCount: Int)
|
||||
data class NotificationResponse(
|
||||
val id: UUID,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val resourceType: String?,
|
||||
val resourceId: String?,
|
||||
val createdAt: Instant,
|
||||
val readAt: Instant?,
|
||||
)
|
||||
|
||||
private fun Notification.toResponse() = NotificationResponse(
|
||||
id, type, title, body, resourceType, resourceId, createdAt, readAt,
|
||||
)
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import com.all8ai.aioa.notification.domain.NotificationRepository
|
||||
import com.all8ai.aioa.shared.id.UuidV7
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.springframework.http.HttpStatus
|
||||
import org.springframework.stereotype.Service
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import com.all8ai.aioa.shared.security.ToolPermission
|
||||
import com.all8ai.aioa.shared.security.requirePermission
|
||||
import com.all8ai.aioa.notification.domain.PushOutboxRepository
|
||||
import org.springframework.transaction.annotation.Transactional
|
||||
|
||||
@Service
|
||||
class NotificationService(
|
||||
private val repository: NotificationRepository,
|
||||
private val auditService: AuditService,
|
||||
private val pushOutbox: PushOutboxRepository? = null,
|
||||
) {
|
||||
@Transactional
|
||||
fun notify(
|
||||
tenantId: UUID,
|
||||
recipientId: UUID,
|
||||
type: String,
|
||||
title: String,
|
||||
body: String,
|
||||
resourceType: String? = null,
|
||||
resourceId: String? = null,
|
||||
): Notification {
|
||||
val notification = repository.create(
|
||||
Notification(UuidV7.generate(), tenantId, recipientId, type, title, body,
|
||||
resourceType, resourceId, Instant.now(), null),
|
||||
)
|
||||
pushOutbox?.enqueue(notification.id)
|
||||
return notification
|
||||
}
|
||||
|
||||
fun list(actor: CurrentUser): List<Notification> {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
return repository.list(actor.tenantId, actor.id, 100)
|
||||
}
|
||||
|
||||
fun unreadCount(actor: CurrentUser): Int {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
return repository.countUnread(actor.tenantId, actor.id)
|
||||
}
|
||||
|
||||
fun markRead(actor: CurrentUser, id: UUID): Notification {
|
||||
actor.requirePermission(ToolPermission.NOTIFICATION_READ_OWN)
|
||||
val notification = repository.markRead(actor.tenantId, actor.id, id)
|
||||
?: throw ApiException(HttpStatus.NOT_FOUND, "NOTIFICATION_NOT_FOUND", "通知不存在")
|
||||
auditService.recordSuccess(actor, "NOTIFICATION_READ", "NOTIFICATION", id.toString(), null, emptyMap())
|
||||
return notification
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.device.domain.UserDeviceRepository
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.springframework.scheduling.annotation.Scheduled
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class PushDispatcher(
|
||||
private val outbox: PushOutboxRepository,
|
||||
private val devices: UserDeviceRepository,
|
||||
private val gateway: PushGateway,
|
||||
) {
|
||||
@Scheduled(fixedDelayString = "\${aioa.push.dispatch-interval-ms:5000}")
|
||||
fun dispatch() {
|
||||
outbox.findPending(50).forEach(::dispatchOne)
|
||||
}
|
||||
|
||||
private fun dispatchOne(pending: PendingPush) {
|
||||
val notification = pending.notification
|
||||
val tokens = devices.listActivePushTokens(notification.tenantId, notification.recipientId)
|
||||
if (tokens.isEmpty()) return outbox.markDelivered(notification.id)
|
||||
var delivered = false
|
||||
var disabled = false
|
||||
var failed = false
|
||||
tokens.forEach { token ->
|
||||
when (gateway.send(token, notification)) {
|
||||
PushSendResult.DELIVERED -> delivered = true
|
||||
PushSendResult.INVALID_TOKEN -> devices.clearPushToken(token)
|
||||
PushSendResult.DISABLED -> disabled = true
|
||||
PushSendResult.FAILED -> failed = true
|
||||
}
|
||||
}
|
||||
when {
|
||||
delivered || (!disabled && !failed) -> outbox.markDelivered(notification.id)
|
||||
disabled -> outbox.reschedule(notification.id, pending.attempts, 3600, "Firebase push is not configured")
|
||||
else -> {
|
||||
val attempts = pending.attempts + 1
|
||||
outbox.reschedule(notification.id, attempts, minOf(3600, 1L shl minOf(attempts, 10)), "Firebase delivery failed")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.all8ai.aioa.notification.domain
|
||||
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
data class Notification(
|
||||
val id: UUID,
|
||||
val tenantId: UUID,
|
||||
val recipientId: UUID,
|
||||
val type: String,
|
||||
val title: String,
|
||||
val body: String,
|
||||
val resourceType: String?,
|
||||
val resourceId: String?,
|
||||
val createdAt: Instant,
|
||||
val readAt: Instant?,
|
||||
)
|
||||
|
||||
interface NotificationRepository {
|
||||
fun create(notification: Notification): Notification
|
||||
fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification>
|
||||
fun countUnread(tenantId: UUID, recipientId: UUID): Int
|
||||
fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification?
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.all8ai.aioa.notification.domain
|
||||
|
||||
import java.util.UUID
|
||||
|
||||
data class PendingPush(val notification: Notification, val attempts: Int)
|
||||
|
||||
interface PushOutboxRepository {
|
||||
fun enqueue(notificationId: UUID)
|
||||
fun findPending(limit: Int): List<PendingPush>
|
||||
fun markDelivered(notificationId: UUID)
|
||||
fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String)
|
||||
}
|
||||
|
||||
enum class PushSendResult { DELIVERED, INVALID_TOKEN, DISABLED, FAILED }
|
||||
|
||||
fun interface PushGateway {
|
||||
fun send(token: String, notification: Notification): PushSendResult
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.google.auth.oauth2.GoogleCredentials
|
||||
import com.google.firebase.FirebaseApp
|
||||
import com.google.firebase.FirebaseOptions
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import com.google.firebase.messaging.FirebaseMessagingException
|
||||
import com.google.firebase.messaging.Message
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.springframework.beans.factory.annotation.Value
|
||||
import org.springframework.stereotype.Component
|
||||
import java.io.FileInputStream
|
||||
|
||||
@Component
|
||||
class FirebasePushGateway(
|
||||
@Value("\${aioa.push.firebase-credentials-file:}") credentialsFile: String,
|
||||
) : PushGateway {
|
||||
private val messaging: FirebaseMessaging? = credentialsFile.trim().takeIf { it.isNotEmpty() }?.let { path ->
|
||||
val options = FileInputStream(path).use { FirebaseOptions.builder().setCredentials(GoogleCredentials.fromStream(it)).build() }
|
||||
FirebaseMessaging.getInstance(FirebaseApp.initializeApp(options, "aioa-push"))
|
||||
}
|
||||
|
||||
override fun send(token: String, notification: Notification): PushSendResult {
|
||||
val client = messaging ?: return PushSendResult.DISABLED
|
||||
val message = Message.builder().setToken(token)
|
||||
.setNotification(com.google.firebase.messaging.Notification.builder().setTitle(notification.title).setBody(notification.body).build())
|
||||
.putData("type", notification.type)
|
||||
.apply {
|
||||
notification.resourceType?.let { putData("resourceType", it) }
|
||||
notification.resourceId?.let { putData("resourceId", it) }
|
||||
}.build()
|
||||
return try {
|
||||
client.send(message)
|
||||
PushSendResult.DELIVERED
|
||||
} catch (exception: FirebaseMessagingException) {
|
||||
if (exception.messagingErrorCode?.name in setOf("UNREGISTERED", "INVALID_ARGUMENT")) PushSendResult.INVALID_TOKEN else PushSendResult.FAILED
|
||||
} catch (_: Exception) {
|
||||
PushSendResult.FAILED
|
||||
}
|
||||
}
|
||||
}
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.all8ai.aioa.notification.domain.Notification
|
||||
import com.all8ai.aioa.notification.domain.NotificationRepository
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqNotificationRepository(private val dsl: DSLContext) : NotificationRepository {
|
||||
override fun create(notification: Notification): Notification {
|
||||
dsl.execute(
|
||||
"""
|
||||
INSERT INTO communication.notification (
|
||||
id, tenant_id, recipient_id, type, title, body,
|
||||
resource_type, resource_id
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""".trimIndent(),
|
||||
notification.id, notification.tenantId, notification.recipientId,
|
||||
notification.type, notification.title, notification.body,
|
||||
notification.resourceType, notification.resourceId,
|
||||
)
|
||||
return notification
|
||||
}
|
||||
|
||||
override fun list(tenantId: UUID, recipientId: UUID, limit: Int): List<Notification> = dsl.fetch(
|
||||
"""
|
||||
SELECT * FROM communication.notification
|
||||
WHERE tenant_id = ? AND recipient_id = ?
|
||||
ORDER BY created_at DESC, id DESC LIMIT ?
|
||||
""".trimIndent(),
|
||||
tenantId, recipientId, limit,
|
||||
).map(::map)
|
||||
|
||||
override fun countUnread(tenantId: UUID, recipientId: UUID): Int = dsl.fetchOne(
|
||||
"SELECT COUNT(*) AS count FROM communication.notification WHERE tenant_id = ? AND recipient_id = ? AND read_at IS NULL",
|
||||
tenantId, recipientId,
|
||||
)!!.get("count", Int::class.java)!!
|
||||
|
||||
override fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? = dsl.fetchOne(
|
||||
"""
|
||||
UPDATE communication.notification SET read_at = COALESCE(read_at, CURRENT_TIMESTAMP)
|
||||
WHERE tenant_id = ? AND recipient_id = ? AND id = ? RETURNING *
|
||||
""".trimIndent(),
|
||||
tenantId, recipientId, id,
|
||||
)?.let(::map)
|
||||
|
||||
private fun map(record: Record) = Notification(
|
||||
id = record.get("id", UUID::class.java)!!,
|
||||
tenantId = record.get("tenant_id", UUID::class.java)!!,
|
||||
recipientId = record.get("recipient_id", UUID::class.java)!!,
|
||||
type = record.get("type", String::class.java)!!,
|
||||
title = record.get("title", String::class.java)!!,
|
||||
body = record.get("body", String::class.java)!!,
|
||||
resourceType = record.get("resource_type", String::class.java),
|
||||
resourceId = record.get("resource_id", String::class.java),
|
||||
createdAt = record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
readAt = record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
)
|
||||
}
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
package com.all8ai.aioa.notification.infrastructure
|
||||
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.jooq.DSLContext
|
||||
import org.jooq.Record
|
||||
import org.springframework.stereotype.Repository
|
||||
import java.time.OffsetDateTime
|
||||
import java.util.UUID
|
||||
|
||||
@Repository
|
||||
class JooqPushOutboxRepository(private val dsl: DSLContext) : PushOutboxRepository {
|
||||
override fun enqueue(notificationId: UUID) {
|
||||
dsl.execute("INSERT INTO communication.notification_push_outbox (notification_id) VALUES (?) ON CONFLICT DO NOTHING", notificationId)
|
||||
}
|
||||
|
||||
override fun findPending(limit: Int): List<PendingPush> = dsl.fetch(
|
||||
"""
|
||||
SELECT n.*, o.attempts FROM communication.notification_push_outbox o
|
||||
JOIN communication.notification n ON n.id = o.notification_id
|
||||
WHERE o.status = 'PENDING' AND o.next_attempt_at <= CURRENT_TIMESTAMP
|
||||
ORDER BY o.next_attempt_at, o.notification_id LIMIT ?
|
||||
""".trimIndent(), limit,
|
||||
).map(::map)
|
||||
|
||||
override fun markDelivered(notificationId: UUID) {
|
||||
dsl.execute("UPDATE communication.notification_push_outbox SET status = 'DELIVERED', delivered_at = CURRENT_TIMESTAMP WHERE notification_id = ?", notificationId)
|
||||
}
|
||||
|
||||
override fun reschedule(notificationId: UUID, attempts: Int, delaySeconds: Long, error: String) {
|
||||
dsl.execute(
|
||||
"UPDATE communication.notification_push_outbox SET attempts = ?, next_attempt_at = CURRENT_TIMESTAMP + (? * INTERVAL '1 second'), last_error = ? WHERE notification_id = ?",
|
||||
attempts, delaySeconds, error.take(500), notificationId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun map(record: Record) = PendingPush(
|
||||
Notification(
|
||||
record.get("id", UUID::class.java)!!, record.get("tenant_id", UUID::class.java)!!,
|
||||
record.get("recipient_id", UUID::class.java)!!, record.get("type", String::class.java)!!,
|
||||
record.get("title", String::class.java)!!, record.get("body", String::class.java)!!,
|
||||
record.get("resource_type", String::class.java), record.get("resource_id", String::class.java),
|
||||
record.get("created_at", OffsetDateTime::class.java)!!.toInstant(),
|
||||
record.get("read_at", OffsetDateTime::class.java)?.toInstant(),
|
||||
),
|
||||
record.get("attempts", Int::class.java)!!,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,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,
|
||||
)
|
||||
|
||||
+16
@@ -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);
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
package com.all8ai.aioa.ai.application
|
||||
|
||||
import com.all8ai.aioa.ai.domain.AiLeaveDraftGateway
|
||||
import com.all8ai.aioa.ai.domain.LeaveDraftSuggestion
|
||||
import com.all8ai.aioa.ai.domain.SuggestedLeaveDraft
|
||||
import com.all8ai.aioa.audit.application.AuditService
|
||||
import com.all8ai.aioa.identity.domain.CurrentUser
|
||||
import com.all8ai.aioa.shared.web.ApiException
|
||||
import org.assertj.core.api.Assertions.assertThat
|
||||
import org.assertj.core.api.Assertions.assertThatThrownBy
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.mockito.Mockito.mock
|
||||
import org.springframework.http.HttpStatus
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
|
||||
class AiLeaveDraftServiceTest {
|
||||
private val actor = CurrentUser(UUID.randomUUID(), UUID.randomUUID(), "employee", "员工", null, null, null, setOf("employee"))
|
||||
|
||||
@Test
|
||||
fun `returns suggestion that still requires user confirmation`() {
|
||||
val gateway = AiLeaveDraftGateway {
|
||||
_, _ -> SuggestedLeaveDraft(
|
||||
LeaveDraftSuggestion("PERSONAL", Instant.parse("2026-07-19T05:30:00Z"),
|
||||
Instant.parse("2026-07-19T09:30:00Z"), "办理个人事务", emptyList(), emptyList()),
|
||||
"qwen-plus", true,
|
||||
)
|
||||
}
|
||||
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
|
||||
|
||||
val result = service.suggest(actor, "明天下午请事假四小时", "Asia/Shanghai")
|
||||
|
||||
assertThat(result.suggestion.type).isEqualTo("PERSONAL")
|
||||
assertThat(result.requiresUserConfirmation).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rejects model response that could bypass confirmation`() {
|
||||
val gateway = AiLeaveDraftGateway { _, _ ->
|
||||
SuggestedLeaveDraft(LeaveDraftSuggestion(null, null, null, null, emptyList(), emptyList()), "qwen-plus", false)
|
||||
}
|
||||
val service = AiLeaveDraftService(gateway, mock(AuditService::class.java))
|
||||
|
||||
assertThatThrownBy { service.suggest(actor, "请假", "Asia/Shanghai") }
|
||||
.isInstanceOfSatisfying(ApiException::class.java) {
|
||||
assertThat(it.status).isEqualTo(HttpStatus.BAD_GATEWAY)
|
||||
}
|
||||
}
|
||||
}
|
||||
+81
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
+103
@@ -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) }
|
||||
}
|
||||
}
|
||||
+42
@@ -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)
|
||||
}
|
||||
+29
@@ -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")
|
||||
}
|
||||
}
|
||||
+72
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
package com.all8ai.aioa.notification.application
|
||||
|
||||
import com.all8ai.aioa.device.domain.UserDeviceRepository
|
||||
import com.all8ai.aioa.notification.domain.*
|
||||
import org.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)
|
||||
}
|
||||
}
|
||||
+39
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,避免泄露资源是否存在。
|
||||
@@ -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 并清除本机凭据。
|
||||
|
||||
@@ -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 配置存在时启用,缺失时降级站内通知)
|
||||
|
||||
## M3:AI 最小闭环
|
||||
|
||||
- 自然语言生成请假草稿
|
||||
- 查询本人流程进度
|
||||
- 确认卡片、工具鉴权和 AI 审计
|
||||
- [x] 千问自然语言生成受约束请假草稿建议
|
||||
- [x] 查询本人流程进度
|
||||
- [x] 确认卡片、Kotlin 代理鉴权和 AI 审计
|
||||
|
||||
## Definition of Done
|
||||
|
||||
|
||||
@@ -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 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"
|
||||
#include "Generated.xcconfig"
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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']!),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user