diff --git a/.env.example b/.env.example index 723a94b..a32c654 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index 11d7353..c597bcc 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..bc9b69e --- /dev/null +++ b/.gitlab-ci.yml @@ -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 diff --git a/README.md b/README.md index 5446131..b4b5622 100644 --- a/README.md +++ b/README.md @@ -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。 diff --git a/ai-service/.dockerignore b/ai-service/.dockerignore new file mode 100644 index 0000000..ad03878 --- /dev/null +++ b/ai-service/.dockerignore @@ -0,0 +1,4 @@ +.venv +__pycache__ +.pytest_cache +tests diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..3b4ff2a --- /dev/null +++ b/ai-service/Dockerfile @@ -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"] diff --git a/ai-service/README.md b/ai-service/README.md index 01bcaba..a8b8f1f 100644 --- a/ai-service/README.md +++ b/ai-service/README.md @@ -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 白名单、枚举、长度、时区和时间范围校验,结果始终要求用户确认。 diff --git a/ai-service/app/__init__.py b/ai-service/app/__init__.py new file mode 100644 index 0000000..cd2d2f6 --- /dev/null +++ b/ai-service/app/__init__.py @@ -0,0 +1 @@ +"""AIOA AI service.""" diff --git a/ai-service/app/config.py b/ai-service/app/config.py new file mode 100644 index 0000000..a6d21f9 --- /dev/null +++ b/ai-service/app/config.py @@ -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() diff --git a/ai-service/app/main.py b/ai-service/app/main.py new file mode 100644 index 0000000..e4d6197 --- /dev/null +++ b/ai-service/app/main.py @@ -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) diff --git a/ai-service/app/models.py b/ai-service/app/models.py new file mode 100644 index 0000000..1fc8111 --- /dev/null +++ b/ai-service/app/models.py @@ -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 diff --git a/ai-service/app/qwen.py b/ai-service/app/qwen.py new file mode 100644 index 0000000..ececd1e --- /dev/null +++ b/ai-service/app/qwen.py @@ -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 diff --git a/ai-service/pyproject.toml b/ai-service/pyproject.toml new file mode 100644 index 0000000..13a7cdf --- /dev/null +++ b/ai-service/pyproject.toml @@ -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"] diff --git a/ai-service/tests/test_leave_progress.py b/ai-service/tests/test_leave_progress.py new file mode 100644 index 0000000..16f5ecc --- /dev/null +++ b/ai-service/tests/test_leave_progress.py @@ -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 diff --git a/ai-service/tests/test_leave_suggestion.py b/ai-service/tests/test_leave_suggestion.py new file mode 100644 index 0000000..ac17c57 --- /dev/null +++ b/ai-service/tests/test_leave_suggestion.py @@ -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 diff --git a/backend/boot/build.gradle.kts b/backend/boot/build.gradle.kts index 0d5dc69..7a96775 100644 --- a/backend/boot/build.gradle.kts +++ b/backend/boot/build.gradle.kts @@ -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") diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt index d9e0eb2..93447b4 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/AioaApplication.kt @@ -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) { diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveDraftController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveDraftController.kt new file mode 100644 index 0000000..2d189a7 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveDraftController.kt @@ -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", +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveProgressController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveProgressController.kt new file mode 100644 index 0000000..b66131c --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/api/AiLeaveProgressController.kt @@ -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) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftService.kt new file mode 100644 index 0000000..e63b244 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftService.kt @@ -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 + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressService.kt new file mode 100644 index 0000000..a55e639 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressService.kt @@ -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): List { + 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) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveDraftSuggestion.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveDraftSuggestion.kt new file mode 100644 index 0000000..132adbf --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveDraftSuggestion.kt @@ -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, + val needsClarification: List, +) + +data class SuggestedLeaveDraft( + val suggestion: LeaveDraftSuggestion, + val model: String, + val requiresUserConfirmation: Boolean, +) + +fun interface AiLeaveDraftGateway { + fun suggest(text: String, timezone: String): SuggestedLeaveDraft +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveProgressAnswer.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveProgressAnswer.kt new file mode 100644 index 0000000..ed981e6 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/domain/LeaveProgressAnswer.kt @@ -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, + val completedTaskNames: List, + val processEnded: Boolean, + val timelineEventTypes: List, +) + +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 = emptyList(), + val answer: String? = null, + val request: LeaveProgressCandidate? = null, + val progress: LeaveProgressView? = null, +) + +data class LeaveProgressView( + val activeTaskNames: List, + val completedTaskNames: List, + val processEnded: Boolean, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveDraftGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveDraftGateway.kt new file mode 100644 index 0000000..51bf95b --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveDraftGateway.kt @@ -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) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveProgressGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveProgressGateway.kt new file mode 100644 index 0000000..a452093 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/ai/infrastructure/HttpAiLeaveProgressGateway.kt @@ -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) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt index 347c14d..8743bac 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/ApprovalTaskService.kt @@ -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 = - workflowGateway.listAssignedTasks(actor.id).mapNotNull { task -> + fun listAssigned(actor: CurrentUser): List { + 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 } diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt index c30897a..c2d577f 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/approval/application/LeaveRequestService.kt @@ -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 = repository.listOwn(actor.tenantId, actor.id, 100) + fun listOwn(actor: CurrentUser): List { + 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, diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/api/LeaveAttachmentController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/api/LeaveAttachmentController.kt new file mode 100644 index 0000000..dd0b33b --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/api/LeaveAttachmentController.kt @@ -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, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentService.kt new file mode 100644 index 0000000..ab1acd7 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentService.kt @@ -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 { + 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) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/domain/LeaveAttachment.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/domain/LeaveAttachment.kt new file mode 100644 index 0000000..647e3f8 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/domain/LeaveAttachment.kt @@ -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 + 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?) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/JooqLeaveAttachmentRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/JooqLeaveAttachmentRepository.kt new file mode 100644 index 0000000..412fc5c --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/JooqLeaveAttachmentRepository.kt @@ -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 = 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(), + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/MinioObjectStorageGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/MinioObjectStorageGateway.kt new file mode 100644 index 0000000..bf459d1 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/attachment/infrastructure/MinioObjectStorageGateway.kt @@ -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()) + } + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/api/UserDeviceController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/api/UserDeviceController.kt new file mode 100644 index 0000000..1b8b340 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/api/UserDeviceController.kt @@ -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 = + 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?) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/application/UserDeviceService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/application/UserDeviceService.kt new file mode 100644 index 0000000..81174c1 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/application/UserDeviceService.kt @@ -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 = 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", "设备不存在或已撤销") + } + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/domain/UserDevice.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/domain/UserDevice.kt new file mode 100644 index 0000000..7ca6b20 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/domain/UserDevice.kt @@ -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 + 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 + fun clearPushToken(token: String) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceSessionInterceptor.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceSessionInterceptor.kt new file mode 100644 index 0000000..2750e21 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceSessionInterceptor.kt @@ -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" + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceWebConfiguration.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceWebConfiguration.kt new file mode 100644 index 0000000..aaa6c2b --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/DeviceWebConfiguration.kt @@ -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) + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/JooqUserDeviceRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/JooqUserDeviceRepository.kt new file mode 100644 index 0000000..12b6e85 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/device/infrastructure/JooqUserDeviceRepository.kt @@ -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 = 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 = 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(), + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt new file mode 100644 index 0000000..4d523c1 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/forms/api/FormDefinitionController.kt @@ -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, + val uiSchema: Map, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt index a44c608..7f6bf08 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/identity/api/CurrentUserController.kt @@ -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, + val permissions: Set, + val dataScopes: Set, ) data class OrganizationRef( diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/api/NotificationController.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/api/NotificationController.kt new file mode 100644 index 0000000..5be5ffb --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/api/NotificationController.kt @@ -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, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/NotificationService.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/NotificationService.kt new file mode 100644 index 0000000..e78b1f4 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/NotificationService.kt @@ -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 { + 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 + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/PushDispatcher.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/PushDispatcher.kt new file mode 100644 index 0000000..6ef85c8 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/application/PushDispatcher.kt @@ -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") + } + } + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/Notification.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/Notification.kt new file mode 100644 index 0000000..c3c274a --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/Notification.kt @@ -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 + fun countUnread(tenantId: UUID, recipientId: UUID): Int + fun markRead(tenantId: UUID, recipientId: UUID, id: UUID): Notification? +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/PushDelivery.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/PushDelivery.kt new file mode 100644 index 0000000..e588962 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/domain/PushDelivery.kt @@ -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 + 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 +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/FirebasePushGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/FirebasePushGateway.kt new file mode 100644 index 0000000..406a07e --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/FirebasePushGateway.kt @@ -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 + } + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqNotificationRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqNotificationRepository.kt new file mode 100644 index 0000000..74921da --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqNotificationRepository.kt @@ -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 = 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(), + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqPushOutboxRepository.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqPushOutboxRepository.kt new file mode 100644 index 0000000..0757791 --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/notification/infrastructure/JooqPushOutboxRepository.kt @@ -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 = 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)!!, + ) +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt new file mode 100644 index 0000000..995beeb --- /dev/null +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicy.kt @@ -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, + val dataScopes: Set, +) + +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", "当前用户无权使用该功能") + } +} diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt index 8110a99..a2ac991 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/domain/LeaveWorkflowGateway.kt @@ -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, + val completedTaskNames: List, + val processEnded: Boolean, +) diff --git a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt index 4d18928..06400d2 100644 --- a/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt +++ b/backend/boot/src/main/kotlin/com/all8ai/aioa/workflow/infrastructure/FlowableLeaveWorkflowGateway.kt @@ -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, diff --git a/backend/boot/src/main/resources/application.yaml b/backend/boot/src/main/resources/application.yaml index 8c02559..60a6d32 100644 --- a/backend/boot/src/main/resources/application.yaml +++ b/backend/boot/src/main/resources/application.yaml @@ -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} diff --git a/backend/boot/src/main/resources/db/migration/V10__create_notifications.sql b/backend/boot/src/main/resources/db/migration/V10__create_notifications.sql new file mode 100644 index 0000000..3e89467 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V10__create_notifications.sql @@ -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; diff --git a/backend/boot/src/main/resources/db/migration/V11__create_user_devices.sql b/backend/boot/src/main/resources/db/migration/V11__create_user_devices.sql new file mode 100644 index 0000000..77a5ccf --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V11__create_user_devices.sql @@ -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); diff --git a/backend/boot/src/main/resources/db/migration/V12__create_push_delivery.sql b/backend/boot/src/main/resources/db/migration/V12__create_push_delivery.sql new file mode 100644 index 0000000..6aba386 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V12__create_push_delivery.sql @@ -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'; diff --git a/backend/boot/src/main/resources/db/migration/V9__create_leave_attachments.sql b/backend/boot/src/main/resources/db/migration/V9__create_leave_attachments.sql new file mode 100644 index 0000000..25ce379 --- /dev/null +++ b/backend/boot/src/main/resources/db/migration/V9__create_leave_attachments.sql @@ -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); diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftServiceTest.kt new file mode 100644 index 0000000..0cb17bd --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveDraftServiceTest.kt @@ -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) + } + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressServiceTest.kt new file mode 100644 index 0000000..87e1986 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/ai/application/AiLeaveProgressServiceTest.kt @@ -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") + } + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentServiceTest.kt new file mode 100644 index 0000000..78e370b --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/attachment/application/LeaveAttachmentServiceTest.kt @@ -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() + 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() + 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) } + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/device/application/UserDeviceServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/device/application/UserDeviceServiceTest.kt new file mode 100644 index 0000000..395973e --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/device/application/UserDeviceServiceTest.kt @@ -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) +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/forms/api/FormDefinitionControllerTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/forms/api/FormDefinitionControllerTest.kt new file mode 100644 index 0000000..6b440d1 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/forms/api/FormDefinitionControllerTest.kt @@ -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 { controller.getDefinition("unknown") } + + assertThat(exception.status).isEqualTo(HttpStatus.NOT_FOUND) + assertThat(exception.code).isEqualTo("FORM_DEFINITION_NOT_FOUND") + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/NotificationServiceTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/NotificationServiceTest.kt new file mode 100644 index 0000000..154b8ee --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/NotificationServiceTest.kt @@ -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() + 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 } + } + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/PushDispatcherTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/PushDispatcherTest.kt new file mode 100644 index 0000000..005425f --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/notification/application/PushDispatcherTest.kt @@ -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) + } +} diff --git a/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt new file mode 100644 index 0000000..46018c9 --- /dev/null +++ b/backend/boot/src/test/kotlin/com/all8ai/aioa/shared/security/AuthorizationPolicyTest.kt @@ -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) = CurrentUser( + UUID.randomUUID(), UUID.randomUUID(), "user", "用户", null, null, null, roles, + ) +} diff --git a/contracts/openapi/aioa-v1.yaml b/contracts/openapi/aioa-v1.yaml index 72ebcec..8fac1bc 100644 --- a/contracts/openapi/aioa-v1.yaml +++ b/contracts/openapi/aioa-v1.yaml @@ -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] diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml index 31b8b95..d767e0e 100644 --- a/deploy/compose/compose.yaml +++ b/deploy/compose/compose.yaml @@ -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: diff --git a/deploy/compose/keycloak/realm-aioa.json b/deploy/compose/keycloak/realm-aioa.json index cbc21f6..1da17bc 100644 --- a/deploy/compose/keycloak/realm-aioa.json +++ b/deploy/compose/keycloak/realm-aioa.json @@ -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" diff --git a/docs/engineering/authorization.md b/docs/engineering/authorization.md new file mode 100644 index 0000000..1f50494 --- /dev/null +++ b/docs/engineering/authorization.md @@ -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,避免泄露资源是否存在。 diff --git a/docs/engineering/mobile-schema-forms.md b/docs/engineering/mobile-schema-forms.md index fc3453d..32531b3 100644 --- a/docs/engineering/mobile-schema-forms.md +++ b/docs/engineering/mobile-schema-forms.md @@ -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 并清除本机凭据。 diff --git a/docs/engineering/roadmap.md b/docs/engineering/roadmap.md index caee029..acfb7a3 100644 --- a/docs/engineering/roadmap.md +++ b/docs/engineering/roadmap.md @@ -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 diff --git a/mobile/android/app/build.gradle.kts b/mobile/android/app/build.gradle.kts index 573fb53..84c0099 100644 --- a/mobile/android/app/build.gradle.kts +++ b/mobile/android/app/build.gradle.kts @@ -23,6 +23,7 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + manifestPlaceholders["appAuthRedirectScheme"] = "aioa" } buildTypes { diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index ed703a8..d6f4f4a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -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" diff --git a/mobile/ios/Flutter/Debug.xcconfig b/mobile/ios/Flutter/Debug.xcconfig index 592ceee..ec97fc6 100644 --- a/mobile/ios/Flutter/Debug.xcconfig +++ b/mobile/ios/Flutter/Debug.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig" #include "Generated.xcconfig" diff --git a/mobile/ios/Flutter/Release.xcconfig b/mobile/ios/Flutter/Release.xcconfig index 592ceee..c4855bf 100644 --- a/mobile/ios/Flutter/Release.xcconfig +++ b/mobile/ios/Flutter/Release.xcconfig @@ -1 +1,2 @@ +#include? "Pods/Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig" #include "Generated.xcconfig" diff --git a/mobile/ios/Podfile b/mobile/ios/Podfile new file mode 100644 index 0000000..cf07ade --- /dev/null +++ b/mobile/ios/Podfile @@ -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 diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock new file mode 100644 index 0000000..98fe3e9 --- /dev/null +++ b/mobile/ios/Podfile.lock @@ -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 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 8894fc5..5c29abf 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -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 = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; + 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 = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 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 = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; + 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 = ""; }; 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; 78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; @@ -59,14 +64,28 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 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 = ""; }; + 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 = ""; }; + 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 = ""; }; /* 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 = ""; }; + 8185E7C5804F08FC016736C9 /* Frameworks */ = { + isa = PBXGroup; + children = ( + A0B2050BAA4941AAE206A774 /* Pods_Runner.framework */, + CDB206CCD4C60B56B4582F10 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 9740EEB11CF90186004384FC /* Flutter */ = { isa = PBXGroup; children = ( @@ -100,6 +128,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + CF03A8A68A90DC50946874FE /* Pods */, + 8185E7C5804F08FC016736C9 /* Frameworks */, ); sourceTree = ""; }; @@ -128,6 +158,20 @@ path = Runner; sourceTree = ""; }; + 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 = ""; + }; /* 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; }; diff --git a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/mobile/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/mobile/ios/Runner/Info.plist b/mobile/ios/Runner/Info.plist index 8a27e23..fa22e6c 100644 --- a/mobile/ios/Runner/Info.plist +++ b/mobile/ios/Runner/Info.plist @@ -16,6 +16,17 @@ 6.0 CFBundleName aioa_mobile + CFBundleURLTypes + + + CFBundleTypeRole + Editor + CFBundleURLSchemes + + aioa + + + CFBundlePackageType APPL CFBundleShortVersionString diff --git a/mobile/lib/app/app.dart b/mobile/lib/app/app.dart index ad4d1e5..0e29708 100644 --- a/mobile/lib/app/app.dart +++ b/mobile/lib/app/app.dart @@ -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, diff --git a/mobile/lib/app/router.dart b/mobile/lib/app/router.dart index 923ea6e..458c162 100644 --- a/mobile/lib/app/router.dart +++ b/mobile/lib/app/router.dart @@ -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']!), + ), ], ); diff --git a/mobile/lib/core/auth/auth_session.dart b/mobile/lib/core/auth/auth_session.dart new file mode 100644 index 0000000..81389ab --- /dev/null +++ b/mobile/lib/core/auth/auth_session.dart @@ -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)), + ); +} diff --git a/mobile/lib/core/auth/auth_session_controller.dart b/mobile/lib/core/auth/auth_session_controller.dart new file mode 100644 index 0000000..adf9a81 --- /dev/null +++ b/mobile/lib/core/auth/auth_session_controller.dart @@ -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.new, + ); + +class AuthSessionController extends AsyncNotifier { + static const _storage = FlutterSecureStorage(); + static const _appAuth = FlutterAppAuth(); + + @override + Future build() => _restore(); + + Future 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 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 invalidateDeviceSession() async { + await _clear(); + state = const AsyncData(null); + } + + Future 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 _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 _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 _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 _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'), + ]); + } +} diff --git a/mobile/lib/core/auth/authenticated_http_client.dart b/mobile/lib/core/auth/authenticated_http_client.dart new file mode 100644 index 0000000..87149f7 --- /dev/null +++ b/mobile/lib/core/auth/authenticated_http_client.dart @@ -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((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((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 Function() tokenProvider; + final DeviceRegistration? deviceRegistration; + + @override + Future 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 Function() onRevoked; + String? _registeredToken; + String? _deviceId; + + Future 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 revoked() async { + _registeredToken = null; + await onRevoked(); + } + + Future currentId() async => _deviceId ??= await _loadOrCreateId(); + + Future _loadOrCreateId() async { + final existing = await _storage.read(key: 'device_id'); + if (existing != null) return existing; + final random = Random.secure(); + final bytes = List.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; +} diff --git a/mobile/lib/core/auth/login_page.dart b/mobile/lib/core/auth/login_page.dart new file mode 100644 index 0000000..e4def83 --- /dev/null +++ b/mobile/lib/core/auth/login_page.dart @@ -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 登录'), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/mobile/lib/core/config/runtime_config.dart b/mobile/lib/core/config/runtime_config.dart new file mode 100644 index 0000000..5502137 --- /dev/null +++ b/mobile/lib/core/config/runtime_config.dart @@ -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'; + } +} diff --git a/mobile/lib/core/forms/schema/form_schema.dart b/mobile/lib/core/forms/schema/form_schema.dart index bcd8c08..c5bad5f 100644 --- a/mobile/lib/core/forms/schema/form_schema.dart +++ b/mobile/lib/core/forms/schema/form_schema.dart @@ -10,6 +10,17 @@ class DynamicFormDefinition { final JsonFormSchema dataSchema; final FormUiSchema uiSchema; + + factory DynamicFormDefinition.fromJson(Map json) { + return DynamicFormDefinition( + dataSchema: JsonFormSchema.fromJson( + Map.from(json['dataSchema']! as Map), + ), + uiSchema: FormUiSchema.fromJson( + Map.from(json['uiSchema']! as Map), + ), + ); + } } class JsonFormSchema { @@ -24,6 +35,23 @@ class JsonFormSchema { final String title; final Map properties; final Set required; + + factory JsonFormSchema.fromJson(Map json) { + final properties = Map.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.from(value as Map)), + ), + ), + required: ((json['required'] as List?) ?? const []) + .cast() + .toSet(), + ); + } } class JsonFieldSchema { @@ -40,6 +68,16 @@ class JsonFieldSchema { final List enumValues; final int? minLength; final int? maxLength; + + factory JsonFieldSchema.fromJson(Map json) { + return JsonFieldSchema( + type: JsonValueType.values.byName(json['type']! as String), + format: json['format'] as String?, + enumValues: ((json['enum'] as List?) ?? const []).cast(), + minLength: json['minLength'] as int?, + maxLength: json['maxLength'] as int?, + ); + } } class FormUiSchema { @@ -47,6 +85,19 @@ class FormUiSchema { final String description; final List sections; + + factory FormUiSchema.fromJson(Map json) { + return FormUiSchema( + description: json['description']! as String, + sections: (json['sections']! as List) + .map( + (value) => FormSectionSchema.fromJson( + Map.from(value as Map), + ), + ) + .toList(), + ); + } } class FormSectionSchema { @@ -54,6 +105,19 @@ class FormSectionSchema { final String title; final List controls; + + factory FormSectionSchema.fromJson(Map json) { + return FormSectionSchema( + title: json['title']! as String, + controls: (json['controls']! as List) + .map( + (value) => FormControlSchema.fromJson( + Map.from(value as Map), + ), + ) + .toList(), + ); + } } class FormControlSchema { @@ -72,21 +136,41 @@ class FormControlSchema { final String? placeholder; final String? helperText; final Map optionLabels; + + factory FormControlSchema.fromJson(Map 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(), + ); + } } class DynamicFormState { - const DynamicFormState({this.values = const {}, this.errors = const {}}); + const DynamicFormState({ + this.values = const {}, + this.errors = const {}, + this.restoredAt, + }); final Map values; final Map errors; + final DateTime? restoredAt; DynamicFormState copyWith({ Map? values, Map? errors, + DateTime? restoredAt, + bool clearRestoredAt = false, }) { return DynamicFormState( values: values ?? this.values, errors: errors ?? this.errors, + restoredAt: clearRestoredAt ? null : restoredAt ?? this.restoredAt, ); } } diff --git a/mobile/lib/core/notifications/push_registration_controller.dart b/mobile/lib/core/notifications/push_registration_controller.dart new file mode 100644 index 0000000..ccc8a09 --- /dev/null +++ b/mobile/lib/core/notifications/push_registration_controller.dart @@ -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.new, + ); + +class PushRegistrationController extends AsyncNotifier { + @override + Future 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 _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'); + } + } +} diff --git a/mobile/lib/features/assistant/application/leave_progress_controller.dart b/mobile/lib/features/assistant/application/leave_progress_controller.dart new file mode 100644 index 0000000..31278dc --- /dev/null +++ b/mobile/lib/features/assistant/application/leave_progress_controller.dart @@ -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.new, + ); + +class LeaveProgressController extends AsyncNotifier { + String _question = ''; + @override + Future build() async => null; + Future 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), + ); + } +} diff --git a/mobile/lib/features/assistant/data/leave_progress_repository.dart b/mobile/lib/features/assistant/data/leave_progress_repository.dart new file mode 100644 index 0000000..4b830f9 --- /dev/null +++ b/mobile/lib/features/assistant/data/leave_progress_repository.dart @@ -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 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 candidates; + final String? answer; + final ProgressCandidate? request; + final List activeTasks, completedTasks; + factory ProgressAnswer.fromJson(Map j) { + final p = j['progress'] as Map?; + return ProgressAnswer( + requiresSelection: j['requiresSelection']! as bool, + candidates: ((j['candidates'] as List?) ?? const []) + .map( + (e) => + ProgressCandidate.fromJson(Map.from(e as Map)), + ) + .toList(), + answer: j['answer'] as String?, + request: j['request'] == null + ? null + : ProgressCandidate.fromJson( + Map.from(j['request']! as Map), + ), + activeTasks: ((p?['activeTaskNames'] as List?) ?? const []) + .cast(), + completedTasks: ((p?['completedTaskNames'] as List?) ?? const []) + .cast(), + processEnded: p?['processEnded'] as bool? ?? false, + ); + } +} + +class LeaveProgressRepository { + LeaveProgressRepository({required this.client, required this.baseUrl}); + final http.Client client; + final String baseUrl; + Future 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, + ); + } +} diff --git a/mobile/lib/features/assistant/presentation/assistant_page.dart b/mobile/lib/features/assistant/presentation/assistant_page.dart index 2256e28..278900d 100644 --- a/mobile/lib/features/assistant/presentation/assistant_page.dart +++ b/mobile/lib/features/assistant/presentation/assistant_page.dart @@ -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 createState() => _AssistantPageState(); +} + +class _AssistantPageState extends ConsumerState { + 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 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; } diff --git a/mobile/lib/features/form/application/ai_leave_suggestion_provider.dart b/mobile/lib/features/form/application/ai_leave_suggestion_provider.dart new file mode 100644 index 0000000..9fc6475 --- /dev/null +++ b/mobile/lib/features/form/application/ai_leave_suggestion_provider.dart @@ -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( + (ref) => AiLeaveSuggestionRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), + ); diff --git a/mobile/lib/features/form/application/form_definition_controller.dart b/mobile/lib/features/form/application/form_definition_controller.dart new file mode 100644 index 0000000..fe94129 --- /dev/null +++ b/mobile/lib/features/form/application/form_definition_controller.dart @@ -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( + (ref) => FormDefinitionRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), +); + +final leaveFormDefinitionProvider = FutureProvider((ref) { + return ref.watch(formDefinitionRepositoryProvider).loadLeaveRequest(); +}); diff --git a/mobile/lib/features/form/application/leave_attachment_controller.dart b/mobile/lib/features/form/application/leave_attachment_controller.dart new file mode 100644 index 0000000..2ab174c --- /dev/null +++ b/mobile/lib/features/form/application/leave_attachment_controller.dart @@ -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( + (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 items; + final bool uploading; + final double progress; + final String? error; + + LeaveAttachmentState copyWith({ + List? 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 { + LeaveAttachmentController(this._leaveRequestId); + + final String _leaveRequestId; + + @override + LeaveAttachmentState build() => const LeaveAttachmentState(); + + Future 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 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); + } + } +} diff --git a/mobile/lib/features/form/application/leave_draft_controller.dart b/mobile/lib/features/form/application/leave_draft_controller.dart index 1b0b89c..0783693 100644 --- a/mobile/lib/features/form/application/leave_draft_controller.dart +++ b/mobile/lib/features/form/application/leave_draft_controller.dart @@ -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( + (ref) => LeaveLocalDraftRepository(), +); + final leaveDraftProvider = NotifierProvider( LeaveDraftController.new, ); class LeaveDraftController extends Notifier { + late final LeaveLocalDraftRepository _repository; + @override - DynamicFormState build() => const DynamicFormState(); + DynamicFormState build() { + _repository = ref.watch(leaveLocalDraftRepositoryProvider); + unawaited(_restore()); + return const DynamicFormState(); + } + + Future _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 { 'reason': '办理个人事务,已提前完成工作交接。', }, ); + unawaited(_repository.save(state.values)); } - bool validate() { - final errors = validateDynamicForm( - leaveFormDefinition.dataSchema, - state.values, - ); + void applySuggestion(Map suggestion) { + final values = {...state.values, ...suggestion}; + state = DynamicFormState(values: values); + unawaited(_repository.save(values)); + } + + Future 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? ?? '', ); diff --git a/mobile/lib/features/form/application/leave_submission_controller.dart b/mobile/lib/features/form/application/leave_submission_controller.dart new file mode 100644 index 0000000..18a3a01 --- /dev/null +++ b/mobile/lib/features/form/application/leave_submission_controller.dart @@ -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( + (ref) => LeaveDraftSubmissionRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), + ); + +final leaveSubmissionProvider = + NotifierProvider( + LeaveSubmissionController.new, + ); + +class LeaveSubmissionState { + const LeaveSubmissionState({this.submitting = false, this.error}); + + final bool submitting; + final String? error; +} + +class LeaveSubmissionController extends Notifier { + @override + LeaveSubmissionState build() => const LeaveSubmissionState(); + + Future submit(Map 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; + } + } +} diff --git a/mobile/lib/features/form/data/ai_leave_suggestion_repository.dart b/mobile/lib/features/form/data/ai_leave_suggestion_repository.dart new file mode 100644 index 0000000..4029e2d --- /dev/null +++ b/mobile/lib/features/form/data/ai_leave_suggestion_repository.dart @@ -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 values; + final List assumptions; + final List 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 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)['detail'] + as String?; + } catch (_) {} + throw AiLeaveSuggestionException( + message ?? 'AI 建议生成失败(${response.statusCode})', + ); + } + final json = jsonDecode(response.body) as Map; + if (json['requiresUserConfirmation'] != true) { + throw const AiLeaveSuggestionException('AI 响应缺少用户确认保护'); + } + final suggestion = Map.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(), + needsClarification: + ((suggestion['needsClarification'] as List?) ?? const []) + .cast(), + model: json['model']! as String, + ); + } +} diff --git a/mobile/lib/features/form/data/form_definition_repository.dart b/mobile/lib/features/form/data/form_definition_repository.dart new file mode 100644 index 0000000..0d2387e --- /dev/null +++ b/mobile/lib/features/form/data/form_definition_repository.dart @@ -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 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; + 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, + ), + source: FormDefinitionSource.cache, + ); + } catch (_) { + await preferences.remove(_cacheKey); + } + } + return const LoadedFormDefinition( + definition: leaveFormDefinition, + source: FormDefinitionSource.bundled, + ); + } + } +} diff --git a/mobile/lib/features/form/data/leave_attachment_repository.dart b/mobile/lib/features/form/data/leave_attachment_repository.dart new file mode 100644 index 0000000..1b07d37 --- /dev/null +++ b/mobile/lib/features/form/data/leave_attachment_repository.dart @@ -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 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 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; + final attachment = Map.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, + ); + } + + Future> 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.from(item as Map), + ), + ) + .toList(); + } + + Map get _authHeaders => { + 'Accept': 'application/json', + if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken', + }; + + Map get _jsonHeaders => { + ..._authHeaders, + 'Content-Type': 'application/json', + }; + + void _requireSuccess(http.Response response, Set expected) { + if (expected.contains(response.statusCode)) return; + String? message; + try { + final problem = jsonDecode(response.body) as Map; + message = problem['detail'] as String?; + } catch (_) {} + throw LeaveAttachmentException(message ?? '附件请求失败(${response.statusCode})'); + } +} diff --git a/mobile/lib/features/form/data/leave_draft_submission_repository.dart b/mobile/lib/features/form/data/leave_draft_submission_repository.dart new file mode 100644 index 0000000..c2d8de8 --- /dev/null +++ b/mobile/lib/features/form/data/leave_draft_submission_repository.dart @@ -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 create(Map 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; + await preferences.remove(pendingStorageKey); + return CreatedLeaveDraft( + id: json['id']! as String, + status: json['status']! as String, + version: json['version']! as int, + ); + } + + Map _requestBody(Map 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; + 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; + 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; +} diff --git a/mobile/lib/features/form/data/leave_local_draft_repository.dart b/mobile/lib/features/form/data/leave_local_draft_repository.dart new file mode 100644 index 0000000..00670e0 --- /dev/null +++ b/mobile/lib/features/form/data/leave_local_draft_repository.dart @@ -0,0 +1,47 @@ +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +class RestoredLeaveDraft { + const RestoredLeaveDraft({required this.values, required this.savedAt}); + + final Map values; + final DateTime savedAt; +} + +class LeaveLocalDraftRepository { + static const storageKey = 'leave-request.local-draft.v1'; + + Future save(Map values) async { + final preferences = await SharedPreferences.getInstance(); + await preferences.setString( + storageKey, + jsonEncode({ + 'version': 1, + 'savedAt': DateTime.now().toUtc().toIso8601String(), + 'values': values, + }), + ); + } + + Future load() async { + final preferences = await SharedPreferences.getInstance(); + final encoded = preferences.getString(storageKey); + if (encoded == null) return null; + try { + final envelope = jsonDecode(encoded) as Map; + if (envelope['version'] != 1) throw const FormatException(); + final savedAt = DateTime.parse(envelope['savedAt']! as String); + final values = Map.from(envelope['values']! as Map); + return RestoredLeaveDraft(values: values, savedAt: savedAt); + } catch (_) { + await preferences.remove(storageKey); + return null; + } + } + + Future clear() async { + final preferences = await SharedPreferences.getInstance(); + await preferences.remove(storageKey); + } +} diff --git a/mobile/lib/features/form/presentation/leave_attachment_sheet.dart b/mobile/lib/features/form/presentation/leave_attachment_sheet.dart new file mode 100644 index 0000000..f071783 --- /dev/null +++ b/mobile/lib/features/form/presentation/leave_attachment_sheet.dart @@ -0,0 +1,138 @@ +import 'package:aioa_mobile/features/form/application/leave_attachment_controller.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class LeaveAttachmentSheet extends ConsumerStatefulWidget { + const LeaveAttachmentSheet({required this.leaveRequestId, super.key}); + + final String leaveRequestId; + + @override + ConsumerState createState() => + _LeaveAttachmentSheetState(); +} + +class _LeaveAttachmentSheetState extends ConsumerState { + @override + void initState() { + super.initState(); + Future.microtask( + () => ref + .read(leaveAttachmentProvider(widget.leaveRequestId).notifier) + .load(), + ); + } + + @override + Widget build(BuildContext context) { + final state = ref.watch(leaveAttachmentProvider(widget.leaveRequestId)); + return SafeArea( + child: Padding( + padding: EdgeInsets.fromLTRB( + 20, + 0, + 20, + 20 + MediaQuery.viewInsetsOf(context).bottom, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + '添加附件', + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 6), + const Text('支持 JPEG、PNG、PDF,单个文件不超过 10 MB。文件将直接上传至对象存储。'), + if (state.error != null) ...[ + const SizedBox(height: 10), + Text( + state.error!, + style: TextStyle(color: Theme.of(context).colorScheme.error), + ), + ], + if (state.uploading) ...[ + const SizedBox(height: 14), + LinearProgressIndicator(value: state.progress), + const SizedBox(height: 6), + Text('正在上传 ${(state.progress * 100).round()}%'), + ], + if (state.items.isNotEmpty) ...[ + const SizedBox(height: 14), + for (final item in state.items) + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + item.contentType == 'application/pdf' + ? Icons.picture_as_pdf_outlined + : Icons.image_outlined, + ), + title: Text( + item.fileName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '${_formatBytes(item.sizeBytes)} · ${item.status == 'READY' ? '已上传' : '处理中'}', + ), + trailing: item.status == 'READY' + ? const Icon(Icons.check_circle, color: Colors.green) + : null, + ), + ], + const SizedBox(height: 14), + OutlinedButton.icon( + onPressed: state.uploading ? null : _pickAndUpload, + icon: const Icon(Icons.attach_file), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('选择附件'), + ), + ), + const SizedBox(height: 8), + FilledButton( + onPressed: state.uploading ? null : () => Navigator.pop(context), + child: const Text('完成'), + ), + ], + ), + ), + ); + } + + Future _pickAndUpload() async { + final result = await FilePicker.platform.pickFiles( + type: FileType.custom, + allowedExtensions: const ['jpg', 'jpeg', 'png', 'pdf'], + withData: true, + ); + if (result == null || !mounted) return; + final file = result.files.single; + final bytes = file.bytes; + if (bytes == null) { + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('无法读取所选文件'))); + return; + } + final contentType = switch (file.extension?.toLowerCase()) { + 'jpg' || 'jpeg' => 'image/jpeg', + 'png' => 'image/png', + 'pdf' => 'application/pdf', + _ => null, + }; + if (contentType == null) return; + await ref + .read(leaveAttachmentProvider(widget.leaveRequestId).notifier) + .upload(fileName: file.name, contentType: contentType, bytes: bytes); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } +} diff --git a/mobile/lib/features/form/presentation/leave_form_page.dart b/mobile/lib/features/form/presentation/leave_form_page.dart index 39de761..5cbc4b9 100644 --- a/mobile/lib/features/form/presentation/leave_form_page.dart +++ b/mobile/lib/features/form/presentation/leave_form_page.dart @@ -1,8 +1,13 @@ import 'dart:convert'; import 'package:aioa_mobile/core/forms/presentation/dynamic_form_card.dart'; +import 'package:aioa_mobile/features/form/application/form_definition_controller.dart'; +import 'package:aioa_mobile/features/form/application/ai_leave_suggestion_provider.dart'; import 'package:aioa_mobile/features/form/application/leave_draft_controller.dart'; -import 'package:aioa_mobile/features/form/domain/leave_form_definition.dart'; +import 'package:aioa_mobile/features/form/application/leave_submission_controller.dart'; +import 'package:aioa_mobile/features/form/data/form_definition_repository.dart'; +import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart'; +import 'package:aioa_mobile/features/form/presentation/leave_attachment_sheet.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -13,51 +18,154 @@ class LeaveFormPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final formState = ref.watch(leaveDraftProvider); final controller = ref.read(leaveDraftProvider.notifier); + final loadedDefinition = ref.watch(leaveFormDefinitionProvider); + final submission = ref.watch(leaveSubmissionProvider); return Scaffold( - appBar: AppBar(title: Text(leaveFormDefinition.dataSchema.title)), - body: ListView( - padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), - children: [ - _AiAssistCard(onApply: controller.applyAiSuggestion), - const SizedBox(height: 14), - DynamicFormCard( - definition: leaveFormDefinition, - state: formState, - onChanged: controller.setValue, - ), - const SizedBox(height: 8), - FilledButton.icon( - onPressed: () { - if (!controller.validate()) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('请检查表单中的必填项和时间范围')), - ); - return; - } - showModalBottomSheet( - context: context, - showDragHandle: true, - builder: (context) => _ConfirmationSheet( - values: ref.read(leaveDraftProvider).values, - ), - ); - }, - icon: const Icon(Icons.check_circle_outline), - label: const Padding( - padding: EdgeInsets.symmetric(vertical: 14), - child: Text('检查并确认'), + appBar: AppBar(title: const Text('请假申请')), + body: loadedDefinition.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('表单定义加载失败:$error')), + data: (loaded) => ListView( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 32), + children: [ + _DefinitionSourceBanner(source: loaded.source), + if (formState.restoredAt != null) ...[ + const SizedBox(height: 8), + _RestoredDraftBanner(onClear: controller.clear), + ], + const SizedBox(height: 8), + _AiAssistCard(onApply: controller.applySuggestion), + const SizedBox(height: 14), + DynamicFormCard( + definition: loaded.definition, + state: formState, + onChanged: controller.setValue, ), - ), - ], + const SizedBox(height: 8), + FilledButton.icon( + onPressed: submission.submitting + ? null + : () async { + if (!controller.validate(loaded.definition.dataSchema)) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('请检查表单中的必填项和时间范围')), + ); + return; + } + final confirmed = await showModalBottomSheet( + context: context, + showDragHandle: true, + builder: (context) => _ConfirmationSheet( + values: ref.read(leaveDraftProvider).values, + ), + ); + if (confirmed != true || !context.mounted) return; + final created = await ref + .read(leaveSubmissionProvider.notifier) + .submit(ref.read(leaveDraftProvider).values); + if (!context.mounted) return; + if (created == null) { + final message = ref.read(leaveSubmissionProvider).error; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message ?? '创建草稿失败')), + ); + return; + } + await controller.clear(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('后端草稿已创建:${created.id}')), + ); + await showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => + LeaveAttachmentSheet(leaveRequestId: created.id), + ); + }, + icon: submission.submitting + ? const SizedBox.square( + dimension: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.check_circle_outline), + label: Padding( + padding: EdgeInsets.symmetric(vertical: 14), + child: Text(submission.submitting ? '正在创建草稿…' : '检查并确认'), + ), + ), + ], + ), ), ); } } -class _AiAssistCard extends StatelessWidget { +class _RestoredDraftBanner extends StatelessWidget { + const _RestoredDraftBanner({required this.onClear}); + + final Future Function() onClear; + + @override + Widget build(BuildContext context) { + return MaterialBanner( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + leading: const Icon(Icons.restore), + content: const Text('已恢复上次未完成的本地草稿'), + actions: [TextButton(onPressed: onClear, child: const Text('清除'))], + ); + } +} + +class _DefinitionSourceBanner extends StatelessWidget { + const _DefinitionSourceBanner({required this.source}); + + final FormDefinitionSource source; + + @override + Widget build(BuildContext context) { + final (icon, text) = switch (source) { + FormDefinitionSource.remote => (Icons.cloud_done_outlined, '已加载最新表单定义'), + FormDefinitionSource.cache => ( + Icons.offline_bolt_outlined, + '当前离线,使用已缓存表单定义', + ), + FormDefinitionSource.bundled => ( + Icons.inventory_2_outlined, + '当前离线,使用内置安全表单定义', + ), + }; + return Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text(text, style: Theme.of(context).textTheme.bodySmall), + ), + ], + ); + } +} + +class _AiAssistCard extends ConsumerStatefulWidget { const _AiAssistCard({required this.onApply}); - final VoidCallback onApply; + final void Function(Map values) onApply; + + @override + ConsumerState<_AiAssistCard> createState() => _AiAssistCardState(); +} + +class _AiAssistCardState extends ConsumerState<_AiAssistCard> { + final textController = TextEditingController(text: '明天下午请事假四小时,办理个人事务'); + bool loading = false; + + @override + void dispose() { + textController.dispose(); + super.dispose(); + } @override Widget build(BuildContext context) { @@ -67,29 +175,75 @@ class _AiAssistCard extends StatelessWidget { ).colorScheme.primaryContainer.withValues(alpha: 0.45), child: Padding( padding: const EdgeInsets.all(16), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - const CircleAvatar(child: Icon(Icons.auto_awesome)), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AI 表单助手', + const Row( + children: [ + CircleAvatar(child: Icon(Icons.auto_awesome)), + SizedBox(width: 12), + Expanded( + child: Text( + '千问表单助手', style: TextStyle(fontWeight: FontWeight.w700), ), - SizedBox(height: 4), - Text('示例:帮我填写明天下午的事假申请'), - ], + ), + ], + ), + const SizedBox(height: 12), + TextField( + controller: textController, + minLines: 2, + maxLines: 4, + maxLength: 2000, + decoration: const InputDecoration( + hintText: '例如:明天下午请事假四小时,办理个人事务', + border: OutlineInputBorder(), ), ), - TextButton(onPressed: onApply, child: const Text('自动填写')), + FilledButton.icon( + onPressed: loading ? null : _suggest, + icon: loading + ? const SizedBox.square( + dimension: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.auto_awesome), + label: Text(loading ? '正在生成建议…' : '生成草稿建议'), + ), ], ), ), ); } + + Future _suggest() async { + if (textController.text.trim().isEmpty) return; + setState(() => loading = true); + try { + final suggestion = await ref + .read(aiLeaveSuggestionRepositoryProvider) + .suggest(textController.text); + if (!mounted) return; + widget.onApply(suggestion.values); + final notes = [ + ...suggestion.assumptions, + ...suggestion.needsClarification, + ]; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(notes.isEmpty ? 'AI 建议已填入,请检查并确认' : notes.join(';')), + ), + ); + } on AiLeaveSuggestionException catch (error) { + if (!mounted) return; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(error.message))); + } finally { + if (mounted) setState(() => loading = false); + } + } } class _ConfirmationSheet extends StatelessWidget { @@ -128,10 +282,7 @@ class _ConfirmationSheet extends StatelessWidget { const SizedBox(height: 16), FilledButton( onPressed: () { - Navigator.pop(context); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('演示模式:草稿已通过本地校验,尚未调用后端')), - ); + Navigator.pop(context, true); }, child: const Text('确认创建草稿'), ), diff --git a/mobile/lib/features/profile/application/device_controller.dart b/mobile/lib/features/profile/application/device_controller.dart new file mode 100644 index 0000000..b78a8ba --- /dev/null +++ b/mobile/lib/features/profile/application/device_controller.dart @@ -0,0 +1,40 @@ +import 'package:aioa_mobile/core/auth/auth_session_controller.dart'; +import 'package:aioa_mobile/core/auth/authenticated_http_client.dart'; +import 'package:aioa_mobile/core/config/runtime_config.dart'; +import 'package:aioa_mobile/features/profile/data/device_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final deviceRepositoryProvider = Provider( + (ref) => DeviceRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), +); +final deviceListProvider = + AsyncNotifierProvider>( + DeviceController.new, + ); + +class DeviceController extends AsyncNotifier> { + @override + Future> build() => + ref.read(deviceRepositoryProvider).list(); + Future revoke(String id) async { + try { + final repository = ref.read(deviceRepositoryProvider); + final current = await repository.isCurrent(id); + await repository.revoke(id); + if (current) { + await ref.read(authSessionProvider.notifier).invalidateDeviceSession(); + } else { + state = AsyncData([ + for (final item in state.value ?? const []) + if (item.id != id) item, + ]); + } + return null; + } catch (error) { + return error.toString(); + } + } +} diff --git a/mobile/lib/features/profile/data/device_repository.dart b/mobile/lib/features/profile/data/device_repository.dart new file mode 100644 index 0000000..445db6b --- /dev/null +++ b/mobile/lib/features/profile/data/device_repository.dart @@ -0,0 +1,52 @@ +import 'dart:convert'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:http/http.dart' as http; + +class UserDeviceItem { + const UserDeviceItem({ + required this.id, + required this.name, + required this.platform, + required this.status, + required this.lastSeenAt, + }); + final String id, name, platform, status; + final DateTime lastSeenAt; + factory UserDeviceItem.fromJson(Map json) => UserDeviceItem( + id: json['id']! as String, + name: json['name']! as String, + platform: json['platform']! as String, + status: json['status']! as String, + lastSeenAt: DateTime.parse(json['lastSeenAt']! as String), + ); +} + +class DeviceRepository { + DeviceRepository({required this.client, required this.baseUrl}); + static const _storage = FlutterSecureStorage(); + final http.Client client; + final String baseUrl; + + Future> list() async { + final response = await client.get(Uri.parse('$baseUrl/devices')); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('设备列表加载失败'); + } + return (jsonDecode(response.body) as List) + .map( + (item) => + UserDeviceItem.fromJson(Map.from(item as Map)), + ) + .toList(); + } + + Future revoke(String id) async { + final response = await client.delete(Uri.parse('$baseUrl/devices/$id')); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw Exception('撤销设备失败'); + } + } + + Future isCurrent(String id) async => + await _storage.read(key: 'device_id') == id; +} diff --git a/mobile/lib/features/profile/presentation/profile_page.dart b/mobile/lib/features/profile/presentation/profile_page.dart index f7a6e42..950d102 100644 --- a/mobile/lib/features/profile/presentation/profile_page.dart +++ b/mobile/lib/features/profile/presentation/profile_page.dart @@ -1,9 +1,75 @@ +import 'package:aioa_mobile/core/auth/auth_session_controller.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:aioa_mobile/features/profile/application/device_controller.dart'; +import 'package:intl/intl.dart'; -class ProfilePage extends StatelessWidget { +class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); @override - Widget build(BuildContext context) => - const Center(child: Text('员工小明 · 产品研发部')); + Widget build(BuildContext context, WidgetRef ref) { + final devices = ref.watch(deviceListProvider); + return ListView( + padding: const EdgeInsets.all(18), + children: [ + const Card( + child: ListTile( + leading: CircleAvatar(child: Icon(Icons.person_outline)), + title: Text('企业账号'), + subtitle: Text('身份由 Keycloak OIDC 管理'), + ), + ), + const SizedBox(height: 12), + Text('登录设备', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + ...devices.when( + loading: () => const [Center(child: CircularProgressIndicator())], + error: (error, _) => [ + Card( + child: ListTile( + title: const Text('设备列表加载失败'), + subtitle: Text('$error'), + ), + ), + ], + data: (items) => items + .map( + (device) => Card( + child: ListTile( + leading: Icon( + device.platform == 'IOS' + ? Icons.phone_iphone + : Icons.phone_android, + ), + title: Text(device.name), + subtitle: Text( + '${device.status == 'ACTIVE' ? '已登录' : '已撤销'} · ${DateFormat('MM-dd HH:mm').format(device.lastSeenAt.toLocal())}', + ), + trailing: device.status != 'ACTIVE' + ? null + : IconButton( + tooltip: '撤销并退出', + icon: const Icon(Icons.logout), + onPressed: () => ref + .read(deviceListProvider.notifier) + .revoke(device.id), + ), + ), + ), + ) + .toList(), + ), + const SizedBox(height: 12), + OutlinedButton.icon( + onPressed: ref.read(authSessionProvider.notifier).logout, + icon: const Icon(Icons.logout), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('安全退出'), + ), + ), + ], + ); + } } diff --git a/mobile/lib/features/requests/application/leave_request_controller.dart b/mobile/lib/features/requests/application/leave_request_controller.dart new file mode 100644 index 0000000..35deefb --- /dev/null +++ b/mobile/lib/features/requests/application/leave_request_controller.dart @@ -0,0 +1,80 @@ +import 'package:aioa_mobile/core/auth/authenticated_http_client.dart'; +import 'package:aioa_mobile/core/config/runtime_config.dart'; +import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final leaveRequestRepositoryProvider = Provider( + (ref) => LeaveRequestRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), +); + +final leaveRequestListProvider = + AsyncNotifierProvider>( + LeaveRequestListController.new, + ); + +class LeaveRequestListController extends AsyncNotifier> { + @override + Future> build() => + ref.read(leaveRequestRepositoryProvider).list(); + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(leaveRequestRepositoryProvider).list(), + ); + } +} + +class LeaveRequestDetail { + const LeaveRequestDetail({required this.request, required this.timeline}); + final LeaveRequestItem request; + final List timeline; +} + +final leaveRequestDetailProvider = + AsyncNotifierProvider.family< + LeaveRequestDetailController, + LeaveRequestDetail, + String + >((id) => LeaveRequestDetailController(id)); + +class LeaveRequestDetailController extends AsyncNotifier { + LeaveRequestDetailController(this.id); + final String id; + + @override + Future build() async { + final repository = ref.read(leaveRequestRepositoryProvider); + final results = await Future.wait([ + repository.get(id), + repository.timeline(id), + ]); + return LeaveRequestDetail( + request: results[0] as LeaveRequestItem, + timeline: results[1] as List, + ); + } + + Future transition(String action) async { + final current = state.value; + if (current == null) return '申请尚未加载完成'; + try { + final updated = await ref + .read(leaveRequestRepositoryProvider) + .transition(current.request, action); + final timeline = await ref + .read(leaveRequestRepositoryProvider) + .timeline(id); + state = AsyncData( + LeaveRequestDetail(request: updated, timeline: timeline), + ); + ref.invalidate(leaveRequestListProvider); + return null; + } on LeaveRequestException catch (error) { + return error.message; + } + } +} diff --git a/mobile/lib/features/requests/data/leave_request_repository.dart b/mobile/lib/features/requests/data/leave_request_repository.dart new file mode 100644 index 0000000..19ce3ff --- /dev/null +++ b/mobile/lib/features/requests/data/leave_request_repository.dart @@ -0,0 +1,213 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class LeaveRequestItem { + const LeaveRequestItem({ + required this.id, + required this.type, + required this.startsAt, + required this.endsAt, + required this.reason, + required this.status, + required this.version, + required this.createdAt, + required this.updatedAt, + }); + + final String id; + final String type; + final DateTime startsAt; + final DateTime endsAt; + final String reason; + final String status; + final int version; + final DateTime createdAt; + final DateTime updatedAt; + + factory LeaveRequestItem.fromJson(Map json) => + LeaveRequestItem( + id: json['id']! as String, + type: json['type']! as String, + startsAt: DateTime.parse(json['startsAt']! as String), + endsAt: DateTime.parse(json['endsAt']! as String), + reason: json['reason']! as String, + status: json['status']! as String, + version: json['version']! as int, + createdAt: DateTime.parse(json['createdAt']! as String), + updatedAt: DateTime.parse(json['updatedAt']! as String), + ); +} + +class LeaveTimelineEvent { + const LeaveTimelineEvent({ + required this.id, + required this.eventType, + required this.fromStatus, + required this.toStatus, + required this.occurredAt, + }); + + final String id; + final String eventType; + final String fromStatus; + final String toStatus; + final DateTime occurredAt; + + factory LeaveTimelineEvent.fromJson(Map json) => + LeaveTimelineEvent( + id: json['id']! as String, + eventType: json['eventType']! as String, + fromStatus: json['fromStatus']! as String, + toStatus: json['toStatus']! as String, + occurredAt: DateTime.parse(json['occurredAt']! as String), + ); +} + +class LeaveRequestException implements Exception { + const LeaveRequestException(this.message); + final String message; + @override + String toString() => message; +} + +class LeaveRequestRepository { + LeaveRequestRepository({ + 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> list() async { + final response = await _client.get( + Uri.parse('$baseUrl/leave-requests'), + headers: _headers, + ); + _requireSuccess(response); + return (jsonDecode(response.body) as List) + .map( + (item) => + LeaveRequestItem.fromJson(Map.from(item as Map)), + ) + .toList(); + } + + Future get(String id) async { + final response = await _client.get( + Uri.parse('$baseUrl/leave-requests/$id'), + headers: _headers, + ); + _requireSuccess(response); + return LeaveRequestItem.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Future> timeline(String id) async { + final response = await _client.get( + Uri.parse('$baseUrl/leave-requests/$id/timeline'), + headers: _headers, + ); + _requireSuccess(response); + return (jsonDecode(response.body) as List) + .map( + (item) => LeaveTimelineEvent.fromJson( + Map.from(item as Map), + ), + ) + .toList(); + } + + Future transition( + LeaveRequestItem request, + String action, + ) async { + final payload = jsonEncode({'version': request.version}); + final preferences = await SharedPreferences.getInstance(); + final storageKey = 'leave-transition.${request.id}.$action'; + final existing = preferences.getString(storageKey); + final pending = existing == null ? null : _decodePending(existing); + final key = pending?.payload == payload ? pending!.key : _newKey(action); + await preferences.setString( + storageKey, + jsonEncode({'key': key, 'payload': payload}), + ); + + late http.Response response; + try { + response = await _client.post( + Uri.parse('$baseUrl/leave-requests/${request.id}/$action'), + headers: { + ..._headers, + 'Content-Type': 'application/json', + 'Idempotency-Key': key, + }, + body: payload, + ); + } catch (_) { + throw const LeaveRequestException('网络不可用,操作已保存,可安全重试'); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode < 500 && response.statusCode != 401) { + await preferences.remove(storageKey); + } + _requireSuccess(response); + } + await preferences.remove(storageKey); + return LeaveRequestItem.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Map get _headers => { + 'Accept': 'application/json', + if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken', + }; + + void _requireSuccess(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) return; + String? message; + try { + message = + (jsonDecode(response.body) as Map)['detail'] + as String?; + } catch (_) {} + throw LeaveRequestException(message ?? '申请请求失败(${response.statusCode})'); + } + + _PendingTransition? _decodePending(String value) { + try { + final json = jsonDecode(value) as Map; + return _PendingTransition( + json['key']! as String, + json['payload']! as String, + ); + } catch (_) { + return null; + } + } + + String _newKey(String action) { + final random = Random.secure(); + final entropy = List.generate( + 12, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + return 'leave-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy'; + } +} + +class _PendingTransition { + const _PendingTransition(this.key, this.payload); + final String key; + final String payload; +} diff --git a/mobile/lib/features/requests/presentation/leave_request_detail_page.dart b/mobile/lib/features/requests/presentation/leave_request_detail_page.dart new file mode 100644 index 0000000..145d73b --- /dev/null +++ b/mobile/lib/features/requests/presentation/leave_request_detail_page.dart @@ -0,0 +1,187 @@ +import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart'; +import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; + +class LeaveRequestDetailPage extends ConsumerWidget { + const LeaveRequestDetailPage({required this.id, super.key}); + final String id; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final detail = ref.watch(leaveRequestDetailProvider(id)); + return Scaffold( + appBar: AppBar(title: const Text('申请详情')), + body: detail.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center(child: Text('详情加载失败:$error')), + data: (value) => _DetailBody(id: id, detail: value), + ), + ); + } +} + +class _DetailBody extends ConsumerWidget { + const _DetailBody({required this.id, required this.detail}); + final String id; + final LeaveRequestDetail detail; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final request = detail.request; + final formatter = DateFormat('yyyy-MM-dd HH:mm'); + return ListView( + padding: const EdgeInsets.all(16), + children: [ + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _statusLabel(request.status), + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 14), + _row('请假类型', _typeLabel(request.type)), + _row('开始时间', formatter.format(request.startsAt.toLocal())), + _row('结束时间', formatter.format(request.endsAt.toLocal())), + _row('请假原因', request.reason), + ], + ), + ), + ), + const SizedBox(height: 14), + Text( + '流程时间线', + style: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700), + ), + const SizedBox(height: 8), + if (detail.timeline.isEmpty) + const Card( + child: ListTile( + leading: Icon(Icons.edit_note), + title: Text('草稿已创建'), + ), + ) + else + for (final event in detail.timeline) _TimelineTile(event: event), + const SizedBox(height: 16), + if (request.status == 'DRAFT') + FilledButton.icon( + onPressed: () => _transition(context, ref, 'submit'), + icon: const Icon(Icons.send_outlined), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('提交审批'), + ), + ), + if (request.status == 'PENDING') + OutlinedButton.icon( + onPressed: () => _transition(context, ref, 'withdraw'), + icon: const Icon(Icons.undo), + label: const Padding( + padding: EdgeInsets.symmetric(vertical: 12), + child: Text('撤回申请'), + ), + ), + ], + ); + } + + Future _transition( + BuildContext context, + WidgetRef ref, + String action, + ) async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(action == 'submit' ? '提交审批' : '撤回申请'), + content: Text(action == 'submit' ? '提交后将进入审批流程,确认继续?' : '确认撤回当前申请?'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, false), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, true), + child: const Text('确认'), + ), + ], + ), + ); + if (confirmed != true || !context.mounted) return; + final error = await ref + .read(leaveRequestDetailProvider(id).notifier) + .transition(action); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error ?? (action == 'submit' ? '已提交审批' : '已撤回'))), + ); + } + + Widget _row(String label, String value) => Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: 78, child: Text(label)), + Expanded( + child: Text( + value, + style: const TextStyle(fontWeight: FontWeight.w600), + ), + ), + ], + ), + ); + + String _typeLabel(String value) => switch (value) { + 'PERSONAL' => '事假', + 'SICK' => '病假', + 'ANNUAL' => '年假', + _ => value, + }; + String _statusLabel(String value) => switch (value) { + 'DRAFT' => '草稿', + 'PENDING' => '审批中', + 'APPROVED' => '已通过', + 'REJECTED' => '已驳回', + 'WITHDRAWN' => '已撤回', + _ => value, + }; +} + +class _TimelineTile extends StatelessWidget { + const _TimelineTile({required this.event}); + final LeaveTimelineEvent event; + + @override + Widget build(BuildContext context) => Card( + child: ListTile( + leading: const Icon(Icons.radio_button_checked), + title: Text(_eventLabel(event.eventType)), + subtitle: Text( + '${event.fromStatus} → ${event.toStatus}\n${DateFormat('MM-dd HH:mm').format(event.occurredAt.toLocal())}', + ), + isThreeLine: true, + ), + ); + + String _eventLabel(String value) => switch (value) { + 'LEAVE_REQUEST_SUBMITTED' => '申请已提交', + 'LEAVE_REQUEST_WITHDRAWN' => '申请已撤回', + 'LEAVE_REQUEST_APPROVED' => '申请已通过', + 'LEAVE_REQUEST_REJECTED' => '申请已驳回', + 'LEAVE_APPROVAL_TASK_APPROVED' => '审批节点已通过', + 'LEAVE_APPROVAL_TASK_REJECTED' => '审批节点已驳回', + _ => value, + }; +} diff --git a/mobile/lib/features/requests/presentation/leave_request_list_page.dart b/mobile/lib/features/requests/presentation/leave_request_list_page.dart new file mode 100644 index 0000000..99498c2 --- /dev/null +++ b/mobile/lib/features/requests/presentation/leave_request_list_page.dart @@ -0,0 +1,93 @@ +import 'package:aioa_mobile/features/requests/application/leave_request_controller.dart'; +import 'package:aioa_mobile/features/requests/data/leave_request_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 LeaveRequestListPage extends ConsumerWidget { + const LeaveRequestListPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final requests = ref.watch(leaveRequestListProvider); + return Scaffold( + appBar: AppBar(title: const Text('我的请假申请')), + body: requests.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: FilledButton( + onPressed: ref.read(leaveRequestListProvider.notifier).refresh, + child: Text('加载失败,点击重试\n$error'), + ), + ), + data: (items) { + if (items.isEmpty) return const Center(child: Text('暂无请假申请')); + return RefreshIndicator( + onRefresh: ref.read(leaveRequestListProvider.notifier).refresh, + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _RequestCard(request: items[index]), + ), + ); + }, + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: () => context.push('/leave/new'), + icon: const Icon(Icons.add), + label: const Text('发起请假'), + ), + ); + } +} + +class _RequestCard extends StatelessWidget { + const _RequestCard({required this.request}); + final LeaveRequestItem request; + + @override + Widget build(BuildContext context) => Card( + child: ListTile( + onTap: () => context.push('/leave/${request.id}'), + leading: CircleAvatar(child: Icon(_statusIcon(request.status))), + title: Text( + '${_typeLabel(request.type)} · ${_statusLabel(request.status)}', + ), + subtitle: Text( + '${DateFormat('MM-dd HH:mm').format(request.startsAt.toLocal())} — ${DateFormat('MM-dd HH:mm').format(request.endsAt.toLocal())}\n${request.reason}', + maxLines: 3, + overflow: TextOverflow.ellipsis, + ), + isThreeLine: true, + trailing: const Icon(Icons.chevron_right), + ), + ); + + String _typeLabel(String value) => switch (value) { + 'PERSONAL' => '事假', + 'SICK' => '病假', + 'ANNUAL' => '年假', + _ => value, + }; + + String _statusLabel(String value) => switch (value) { + 'DRAFT' => '草稿', + 'PENDING' => '审批中', + 'APPROVED' => '已通过', + 'REJECTED' => '已驳回', + 'WITHDRAWN' => '已撤回', + _ => value, + }; + + IconData _statusIcon(String value) => switch (value) { + 'DRAFT' => Icons.edit_note, + 'PENDING' => Icons.hourglass_top, + 'APPROVED' => Icons.check_circle_outline, + 'REJECTED' => Icons.cancel_outlined, + 'WITHDRAWN' => Icons.undo, + _ => Icons.description_outlined, + }; +} diff --git a/mobile/lib/features/tasks/application/approval_task_controller.dart b/mobile/lib/features/tasks/application/approval_task_controller.dart new file mode 100644 index 0000000..8d233e5 --- /dev/null +++ b/mobile/lib/features/tasks/application/approval_task_controller.dart @@ -0,0 +1,48 @@ +import 'package:aioa_mobile/core/auth/authenticated_http_client.dart'; +import 'package:aioa_mobile/core/config/runtime_config.dart'; +import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final approvalTaskRepositoryProvider = Provider( + (ref) => ApprovalTaskRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), +); + +final approvalTaskProvider = + AsyncNotifierProvider>( + ApprovalTaskController.new, + ); + +class ApprovalTaskController extends AsyncNotifier> { + @override + Future> build() => + ref.read(approvalTaskRepositoryProvider).list(); + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(approvalTaskRepositoryProvider).list(), + ); + } + + Future decide({ + required ApprovalTaskItem task, + required bool approved, + String? comment, + }) async { + try { + await ref + .read(approvalTaskRepositoryProvider) + .decide(task: task, approved: approved, comment: comment); + state = AsyncData([ + for (final item in state.value ?? const []) + if (item.id != task.id) item, + ]); + return null; + } on ApprovalTaskException catch (error) { + return error.message; + } + } +} diff --git a/mobile/lib/features/tasks/application/notification_controller.dart b/mobile/lib/features/tasks/application/notification_controller.dart new file mode 100644 index 0000000..298f5d3 --- /dev/null +++ b/mobile/lib/features/tasks/application/notification_controller.dart @@ -0,0 +1,39 @@ +import 'package:aioa_mobile/core/auth/authenticated_http_client.dart'; +import 'package:aioa_mobile/core/config/runtime_config.dart'; +import 'package:aioa_mobile/features/tasks/data/notification_repository.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +final notificationRepositoryProvider = Provider( + (ref) => NotificationRepository( + client: ref.watch(authenticatedHttpClientProvider), + baseUrl: RuntimeConfig.apiBaseUrl, + ), +); + +final notificationProvider = + AsyncNotifierProvider>( + NotificationController.new, + ); + +class NotificationController extends AsyncNotifier> { + @override + Future> build() => + ref.read(notificationRepositoryProvider).list(); + + Future refresh() async { + state = const AsyncLoading(); + state = await AsyncValue.guard( + () => ref.read(notificationRepositoryProvider).list(), + ); + } + + Future markRead(String id) async { + final current = state.value; + if (current == null) return; + final updated = await ref.read(notificationRepositoryProvider).markRead(id); + state = AsyncData([ + for (final item in current) + if (item.id == id) updated else item, + ]); + } +} diff --git a/mobile/lib/features/tasks/data/approval_task_repository.dart b/mobile/lib/features/tasks/data/approval_task_repository.dart new file mode 100644 index 0000000..5318572 --- /dev/null +++ b/mobile/lib/features/tasks/data/approval_task_repository.dart @@ -0,0 +1,185 @@ +import 'dart:convert'; +import 'dart:math'; + +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; + +class ApprovalTaskItem { + const ApprovalTaskItem({ + required this.id, + required this.name, + required this.createdAt, + required this.leaveRequest, + }); + + final String id; + final String name; + final DateTime createdAt; + final ApprovalLeaveRequest leaveRequest; + + factory ApprovalTaskItem.fromJson(Map json) => + ApprovalTaskItem( + id: json['id']! as String, + name: json['name']! as String, + createdAt: DateTime.parse(json['createdAt']! as String), + leaveRequest: ApprovalLeaveRequest.fromJson( + Map.from(json['leaveRequest']! as Map), + ), + ); +} + +class ApprovalLeaveRequest { + const ApprovalLeaveRequest({ + required this.id, + required this.type, + required this.startsAt, + required this.endsAt, + required this.reason, + required this.status, + required this.version, + }); + + final String id; + final String type; + final DateTime startsAt; + final DateTime endsAt; + final String reason; + final String status; + final int version; + + factory ApprovalLeaveRequest.fromJson(Map json) => + ApprovalLeaveRequest( + id: json['id']! as String, + type: json['type']! as String, + startsAt: DateTime.parse(json['startsAt']! as String), + endsAt: DateTime.parse(json['endsAt']! as String), + reason: json['reason']! as String, + status: json['status']! as String, + version: json['version']! as int, + ); +} + +class ApprovalTaskException implements Exception { + const ApprovalTaskException(this.message); + final String message; + @override + String toString() => message; +} + +class ApprovalTaskRepository { + ApprovalTaskRepository({ + 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> list() async { + final response = await _client.get( + Uri.parse('$baseUrl/approval-tasks'), + headers: _headers, + ); + _requireSuccess(response); + return (jsonDecode(response.body) as List) + .map( + (item) => + ApprovalTaskItem.fromJson(Map.from(item as Map)), + ) + .toList(); + } + + Future decide({ + required ApprovalTaskItem task, + required bool approved, + String? comment, + }) async { + final action = approved ? 'approve' : 'reject'; + final payload = jsonEncode({ + 'version': task.leaveRequest.version, + if (comment != null && comment.trim().isNotEmpty) + 'comment': comment.trim(), + }); + final preferences = await SharedPreferences.getInstance(); + final storageKey = 'approval.pending.${task.id}.$action'; + final existing = preferences.getString(storageKey); + final pending = existing == null ? null : _decodePending(existing); + final idempotencyKey = pending?.payload == payload + ? pending!.key + : _newKey(action); + await preferences.setString( + storageKey, + jsonEncode({'key': idempotencyKey, 'payload': payload}), + ); + + late http.Response response; + try { + response = await _client.post( + Uri.parse('$baseUrl/approval-tasks/${task.id}/$action'), + headers: { + ..._headers, + 'Content-Type': 'application/json', + 'Idempotency-Key': idempotencyKey, + }, + body: payload, + ); + } catch (_) { + throw const ApprovalTaskException('网络不可用,审批请求已保存,可安全重试'); + } + if (response.statusCode < 200 || response.statusCode >= 300) { + if (response.statusCode < 500 && response.statusCode != 401) { + await preferences.remove(storageKey); + } + _requireSuccess(response); + } + await preferences.remove(storageKey); + } + + Map get _headers => { + 'Accept': 'application/json', + if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken', + }; + + void _requireSuccess(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) return; + String? message; + try { + message = + (jsonDecode(response.body) as Map)['detail'] + as String?; + } catch (_) {} + throw ApprovalTaskException(message ?? '待办请求失败(${response.statusCode})'); + } + + _PendingApproval? _decodePending(String encoded) { + try { + final json = jsonDecode(encoded) as Map; + return _PendingApproval( + json['key']! as String, + json['payload']! as String, + ); + } catch (_) { + return null; + } + } + + String _newKey(String action) { + final random = Random.secure(); + final entropy = List.generate( + 12, + (_) => random.nextInt(256).toRadixString(16).padLeft(2, '0'), + ).join(); + return 'approval-$action-${DateTime.now().microsecondsSinceEpoch}-$entropy'; + } +} + +class _PendingApproval { + const _PendingApproval(this.key, this.payload); + final String key; + final String payload; +} diff --git a/mobile/lib/features/tasks/data/notification_repository.dart b/mobile/lib/features/tasks/data/notification_repository.dart new file mode 100644 index 0000000..00dd12e --- /dev/null +++ b/mobile/lib/features/tasks/data/notification_repository.dart @@ -0,0 +1,91 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +class AppNotification { + const AppNotification({ + required this.id, + required this.type, + required this.title, + required this.body, + required this.createdAt, + this.resourceType, + this.resourceId, + this.readAt, + }); + + final String id; + final String type; + final String title; + final String body; + final String? resourceType; + final String? resourceId; + final DateTime createdAt; + final DateTime? readAt; + + bool get isRead => readAt != null; + + factory AppNotification.fromJson(Map json) => + AppNotification( + id: json['id']! as String, + type: json['type']! as String, + title: json['title']! as String, + body: json['body']! as String, + resourceType: json['resourceType'] as String?, + resourceId: json['resourceId'] as String?, + createdAt: DateTime.parse(json['createdAt']! as String), + readAt: json['readAt'] == null + ? null + : DateTime.parse(json['readAt']! as String), + ); +} + +class NotificationRepository { + NotificationRepository({ + 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> list() async { + final response = await _client.get( + Uri.parse('$baseUrl/notifications'), + headers: _headers, + ); + _requireSuccess(response); + return (jsonDecode(response.body) as List) + .map( + (item) => + AppNotification.fromJson(Map.from(item as Map)), + ) + .toList(); + } + + Future markRead(String id) async { + final response = await _client.post( + Uri.parse('$baseUrl/notifications/$id/read'), + headers: _headers, + ); + _requireSuccess(response); + return AppNotification.fromJson( + jsonDecode(response.body) as Map, + ); + } + + Map get _headers => { + 'Accept': 'application/json', + if (accessToken.isNotEmpty) 'Authorization': 'Bearer $accessToken', + }; + + void _requireSuccess(http.Response response) { + if (response.statusCode >= 200 && response.statusCode < 300) return; + throw Exception('通知请求失败(${response.statusCode})'); + } +} diff --git a/mobile/lib/features/tasks/presentation/tasks_page.dart b/mobile/lib/features/tasks/presentation/tasks_page.dart index 79d23a0..0030478 100644 --- a/mobile/lib/features/tasks/presentation/tasks_page.dart +++ b/mobile/lib/features/tasks/presentation/tasks_page.dart @@ -1,8 +1,280 @@ +import 'package:aioa_mobile/features/tasks/application/approval_task_controller.dart'; +import 'package:aioa_mobile/features/tasks/application/notification_controller.dart'; +import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart'; +import 'package:aioa_mobile/features/tasks/data/notification_repository.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; -class TasksPage extends StatelessWidget { +class TasksPage extends ConsumerWidget { const TasksPage({super.key}); @override - Widget build(BuildContext context) => const Center(child: Text('暂无待办')); + Widget build(BuildContext context, WidgetRef ref) { + final notifications = ref.watch(notificationProvider); + final tasks = ref.watch(approvalTaskProvider); + final unread = + notifications.value?.where((item) => !item.isRead).length ?? 0; + final taskCount = tasks.value?.length ?? 0; + return DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('待办与通知'), + bottom: TabBar( + tabs: [ + Tab(text: taskCount == 0 ? '待办' : '待办 ($taskCount)'), + Tab(text: unread == 0 ? '通知' : '通知 ($unread)'), + ], + ), + ), + body: TabBarView( + children: [ + _ApprovalTaskList(tasks: tasks), + _NotificationList(notifications: notifications), + ], + ), + ), + ); + } +} + +class _ApprovalTaskList extends ConsumerWidget { + const _ApprovalTaskList({required this.tasks}); + + final AsyncValue> tasks; + + @override + Widget build(BuildContext context, WidgetRef ref) => tasks.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('待办加载失败:$error'), + const SizedBox(height: 8), + FilledButton( + onPressed: ref.read(approvalTaskProvider.notifier).refresh, + child: const Text('重试'), + ), + ], + ), + ), + data: (items) { + if (items.isEmpty) return const Center(child: Text('暂无待办')); + return RefreshIndicator( + onRefresh: ref.read(approvalTaskProvider.notifier).refresh, + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) => + _ApprovalTaskCard(task: items[index]), + ), + ); + }, + ); +} + +class _ApprovalTaskCard extends ConsumerWidget { + const _ApprovalTaskCard({required this.task}); + + final ApprovalTaskItem task; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final request = task.leaveRequest; + final formatter = DateFormat('MM-dd HH:mm'); + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + const CircleAvatar(child: Icon(Icons.assignment_ind_outlined)), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + task.name, + style: const TextStyle(fontWeight: FontWeight.w700), + ), + Text(_typeLabel(request.type)), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Text( + '${formatter.format(request.startsAt.toLocal())} — ${formatter.format(request.endsAt.toLocal())}', + ), + const SizedBox(height: 6), + Text(request.reason), + const SizedBox(height: 14), + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => _decide(context, ref, approved: false), + child: const Text('驳回'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: () => _decide(context, ref, approved: true), + child: const Text('批准'), + ), + ), + ], + ), + ], + ), + ), + ); + } + + Future _decide( + BuildContext context, + WidgetRef ref, { + required bool approved, + }) async { + final comment = await showDialog( + context: context, + builder: (context) => _DecisionDialog(approved: approved), + ); + if (comment == null || !context.mounted) return; + final error = await ref + .read(approvalTaskProvider.notifier) + .decide(task: task, approved: approved, comment: comment); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(error ?? (approved ? '已批准' : '已驳回'))), + ); + } + + String _typeLabel(String type) => switch (type) { + 'PERSONAL' => '事假', + 'SICK' => '病假', + 'ANNUAL' => '年假', + _ => type, + }; +} + +class _DecisionDialog extends StatefulWidget { + const _DecisionDialog({required this.approved}); + + final bool approved; + + @override + State<_DecisionDialog> createState() => _DecisionDialogState(); +} + +class _DecisionDialogState extends State<_DecisionDialog> { + final controller = TextEditingController(); + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AlertDialog( + title: Text(widget.approved ? '批准申请' : '驳回申请'), + content: TextField( + controller: controller, + maxLength: 1000, + maxLines: 3, + decoration: InputDecoration( + labelText: widget.approved ? '审批意见(选填)' : '驳回原因', + ), + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.pop(context, controller.text), + child: Text(widget.approved ? '确认批准' : '确认驳回'), + ), + ], + ); +} + +class _NotificationList extends ConsumerWidget { + const _NotificationList({required this.notifications}); + + final AsyncValue> notifications; + + @override + Widget build(BuildContext context, WidgetRef ref) => notifications.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (error, _) => Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text('通知加载失败:$error'), + const SizedBox(height: 8), + FilledButton( + onPressed: ref.read(notificationProvider.notifier).refresh, + child: const Text('重试'), + ), + ], + ), + ), + data: (items) { + if (items.isEmpty) return const Center(child: Text('暂无通知')); + return RefreshIndicator( + onRefresh: ref.read(notificationProvider.notifier).refresh, + child: ListView.separated( + padding: const EdgeInsets.all(12), + itemCount: items.length, + separatorBuilder: (_, _) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final item = items[index]; + return Card( + color: item.isRead + ? null + : Theme.of( + context, + ).colorScheme.primaryContainer.withValues(alpha: 0.35), + child: ListTile( + leading: Icon(_icon(item.type)), + title: Text( + item.title, + style: TextStyle( + fontWeight: item.isRead ? FontWeight.w500 : FontWeight.w700, + ), + ), + subtitle: Text( + '${item.body}\n${DateFormat('MM-dd HH:mm').format(item.createdAt.toLocal())}', + ), + isThreeLine: true, + trailing: item.isRead ? null : const Badge(), + onTap: item.isRead + ? null + : () => ref + .read(notificationProvider.notifier) + .markRead(item.id), + ), + ); + }, + ), + ); + }, + ); + + IconData _icon(String type) => switch (type) { + 'APPROVAL_TASK_ASSIGNED' => Icons.assignment_outlined, + 'LEAVE_APPROVED' => Icons.check_circle_outline, + 'LEAVE_REJECTED' => Icons.cancel_outlined, + _ => Icons.notifications_none, + }; } diff --git a/mobile/lib/features/workspace/presentation/workspace_page.dart b/mobile/lib/features/workspace/presentation/workspace_page.dart index b1c9545..cc21291 100644 --- a/mobile/lib/features/workspace/presentation/workspace_page.dart +++ b/mobile/lib/features/workspace/presentation/workspace_page.dart @@ -53,6 +53,21 @@ class WorkspacePage extends StatelessWidget { ), ), ), + const SizedBox(height: 10), + Card( + child: ListTile( + onTap: () => context.push('/leave'), + leading: const CircleAvatar( + child: Icon(Icons.description_outlined), + ), + title: const Text( + '我的请假申请', + style: TextStyle(fontWeight: FontWeight.w700), + ), + subtitle: const Text('查看草稿、审批状态、时间线和撤回申请'), + trailing: const Icon(Icons.chevron_right), + ), + ), const SizedBox(height: 16), const _StatusCard(), ], diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 21163fd..03978f7 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "99.0.0" + _flutterfire_internals: + dependency: transitive + description: + name: _flutterfire_internals + sha256: "460e9e684edb461d85498fc166ff8416f303f22216838d302d80676b348c6a4c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.75" analyzer: dependency: transitive description: @@ -65,6 +73,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" collection: dependency: transitive description: @@ -89,6 +105,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.15.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -105,6 +129,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dbus: + dependency: transitive + description: + name: dbus + sha256: "0ce9b0a839e6dee59a37a623d2fc26a35bbbe6404213e419b0d6411023d62645" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.14" fake_async: dependency: transitive description: @@ -113,6 +145,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" file: dependency: transitive description: @@ -121,6 +161,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "57d9a1dd5063f85fa3107fb42d1faffda52fdc948cefd5fe5ea85267a5fc7343" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.10" + firebase_core: + dependency: "direct main" + description: + name: firebase_core + sha256: "6f22d1c62e0c20976f02cd842c7b7cd3c0f561cc2052586411871045c08860c9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.12.1" + firebase_core_platform_interface: + dependency: transitive + description: + name: firebase_core_platform_interface + sha256: f74d1d6fabccf7743b0144c2ed363d81049e258e22428ebb6fb0faec2fa7d938 + url: "https://pub.flutter-io.cn" + source: hosted + version: "8.0.0" + firebase_core_web: + dependency: transitive + description: + name: firebase_core_web + sha256: ddab99d709b8c27dd47576eb05a1e719e07a2aa45c009a49ae92ac4d2a8ca555 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.9.1" + firebase_messaging: + dependency: "direct main" + description: + name: firebase_messaging + sha256: "30ad2d59bcd86117dc49d278c8998a0fb390c5a3202f6e43e4bd215d3f1d0556" + url: "https://pub.flutter-io.cn" + source: hosted + version: "16.4.3" + firebase_messaging_platform_interface: + dependency: transitive + description: + name: firebase_messaging_platform_interface + sha256: "4d144cb42b9a5a42855596be2d7682d32c50169f42e7df2cb3278ecf935e7d63" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.9.2" + firebase_messaging_web: + dependency: transitive + description: + name: firebase_messaging_web + sha256: fcd25d0b9da55766ef4d28ae05a7460f5189635d6b42607adcd9f08818fd35f0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.2.3" fixnum: dependency: transitive description: @@ -134,6 +230,22 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_appauth: + dependency: "direct main" + description: + name: flutter_appauth + sha256: aafcafdc7de1c412f361798c993de5737a3eddc6bf26fe38a71ad580b37b4ec0 + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.0.2" + flutter_appauth_platform_interface: + dependency: transitive + description: + name: flutter_appauth_platform_interface + sha256: b7c7d4f288af7b3119a9db0ea00cf5e93135d0e83c3687172848bc5c4fdec992 + url: "https://pub.flutter-io.cn" + source: hosted + version: "12.0.1" flutter_lints: dependency: "direct dev" description: @@ -142,6 +254,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.0.0" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.35" flutter_riverpod: dependency: "direct main" description: @@ -150,6 +270,54 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.3.2" + flutter_secure_storage: + dependency: "direct main" + description: + name: flutter_secure_storage + sha256: "7686b1d6a29985dcbb808c59518226e603e3bfa7c0ddfd1a0d00e4cda77c868e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.1" + flutter_secure_storage_darwin: + dependency: transitive + description: + name: flutter_secure_storage_darwin + sha256: "82329fa5cdf343773b1b6897dea959105a29f092454259edff92f9f6637e8149" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.2" + flutter_secure_storage_linux: + dependency: transitive + description: + name: flutter_secure_storage_linux + sha256: a5f35ddab43cf5c8215d2feb4ce1957851f28c5c37e6f04335066a0602087bf5 + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.0.1" + flutter_secure_storage_platform_interface: + dependency: transitive + description: + name: flutter_secure_storage_platform_interface + sha256: "8ceea1223bee3c6ac1a22dabd8feefc550e4729b3675de4b5900f55afcb435d6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.1" + flutter_secure_storage_web: + dependency: transitive + description: + name: flutter_secure_storage_web + sha256: "073a62b3aeb866ab4ce795f960413948e51e5a42a9b0c8333b6daf5bb3208a1c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + flutter_secure_storage_windows: + dependency: transitive + description: + name: flutter_secure_storage_windows + sha256: "3b7c8e068875dfd46719ff57c90d8c459c87f2302ed6b00ff006b3c9fcad1613" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.1.0" flutter_test: dependency: "direct dev" description: flutter @@ -184,6 +352,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "17.3.0" + hooks: + dependency: transitive + description: + name: hooks + sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.2" + http: + dependency: "direct main" + description: + name: http + sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.6.0" http_multi_server: dependency: transitive description: @@ -216,6 +400,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.1" leak_tracker: dependency: transitive description: @@ -296,6 +496,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "9.4.1" package_config: dependency: transitive description: @@ -312,6 +520,78 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider: + dependency: transitive + description: + name: path_provider + sha256: a7f4874f987173da295a61c181b8ee71dab59b332a486b391babf26a1b884825 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.6" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.1" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.6.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.2" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" pool: dependency: transitive description: @@ -328,6 +608,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: @@ -336,6 +624,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.3.2" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "0634e64bd719f89c012f392938e173521f535d3ecaf66558fa94a056d22b5cc7" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.27" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: @@ -533,6 +877,30 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" + win32: + dependency: transitive + description: + name: win32 + sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.15.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" yaml: dependency: transitive description: @@ -543,4 +911,4 @@ packages: version: "3.1.3" sdks: dart: ">=3.12.2 <4.0.0" - flutter: ">=3.38.0" + flutter: ">=3.44.0" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index fd66016..8cfb35b 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -34,9 +34,16 @@ dependencies: # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 + file_picker: 10.3.10 + firebase_core: ^4.2.1 + firebase_messaging: ^16.0.4 + flutter_appauth: ^12.0.2 flutter_riverpod: ^3.3.2 + flutter_secure_storage: ^10.3.1 go_router: ^17.3.0 + http: ^1.5.0 intl: ^0.20.3 + shared_preferences: ^2.5.3 dev_dependencies: flutter_test: diff --git a/mobile/test/core/auth/authenticated_http_client_test.dart b/mobile/test/core/auth/authenticated_http_client_test.dart new file mode 100644 index 0000000..db34345 --- /dev/null +++ b/mobile/test/core/auth/authenticated_http_client_test.dart @@ -0,0 +1,27 @@ +import 'package:aioa_mobile/core/auth/authenticated_http_client.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test( + 'attaches refreshed bearer token only to the configured API origin', + () async { + final captured = []; + final client = AuthenticatedHttpClient( + inner: MockClient((request) async { + captured.add(request); + return http.Response('', 200); + }), + apiOrigin: 'https://api.example.test', + tokenProvider: () async => 'refreshed-token', + ); + + await client.get(Uri.parse('https://api.example.test/api/v1/me')); + await client.put(Uri.parse('https://minio.example.test/upload')); + + expect(captured[0].headers['Authorization'], 'Bearer refreshed-token'); + expect(captured[1].headers['Authorization'], isNull); + }, + ); +} diff --git a/mobile/test/core/forms/form_schema_test.dart b/mobile/test/core/forms/form_schema_test.dart new file mode 100644 index 0000000..a703d5a --- /dev/null +++ b/mobile/test/core/forms/form_schema_test.dart @@ -0,0 +1,56 @@ +import 'package:aioa_mobile/core/forms/schema/form_schema.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('parses server data and UI schemas into safe control models', () { + final definition = DynamicFormDefinition.fromJson({ + 'key': 'leave-request', + 'version': 1, + 'dataSchema': { + r'$id': 'leave-request-v1', + 'title': '请假申请', + 'required': ['type'], + 'properties': { + 'type': { + 'type': 'string', + 'enum': ['PERSONAL', 'SICK'], + }, + }, + }, + 'uiSchema': { + 'description': '远程定义', + 'sections': [ + { + 'title': '请假信息', + 'controls': [ + { + 'field': 'type', + 'label': '请假类型', + 'control': 'select', + 'optionLabels': {'PERSONAL': '事假'}, + }, + ], + }, + ], + }, + }); + + expect(definition.dataSchema.id, 'leave-request-v1'); + expect(definition.dataSchema.required, {'type'}); + expect(definition.dataSchema.properties['type']!.enumValues, [ + 'PERSONAL', + 'SICK', + ]); + expect( + definition.uiSchema.sections.single.controls.single.control, + FormControlType.select, + ); + }); + + test('unknown controls cannot enter the renderer whitelist', () { + expect( + () => FormControlType.values.byName('remoteScript'), + throwsA(isA()), + ); + }); +} diff --git a/mobile/test/features/assistant/leave_progress_repository_test.dart b/mobile/test/features/assistant/leave_progress_repository_test.dart new file mode 100644 index 0000000..c9e9690 --- /dev/null +++ b/mobile/test/features/assistant/leave_progress_repository_test.dart @@ -0,0 +1,69 @@ +import 'dart:convert'; +import 'package:aioa_mobile/features/assistant/data/leave_progress_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test('parses ambiguous own request candidates', () async { + final repository = LeaveProgressRepository( + baseUrl: 'https://api.example.test/api/v1', + client: MockClient((request) async { + expect(request.url.path, '/api/v1/ai/leave-progress-answers'); + return http.Response( + jsonEncode({ + 'requiresSelection': true, + 'candidates': [ + { + 'id': 'leave-1', + 'type': 'ANNUAL', + 'status': 'PENDING', + 'startsAt': '2026-07-20T01:00:00Z', + 'endsAt': '2026-07-20T09:00:00Z', + 'createdAt': '2026-07-18T01:00:00Z', + }, + ], + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + final result = await repository.ask('我的年假进度?'); + expect(result.requiresSelection, isTrue); + expect(result.candidates.single.id, 'leave-1'); + }); + + test('sends selected request id and parses active tasks', () async { + final repository = LeaveProgressRepository( + baseUrl: 'https://api.example.test/api/v1', + client: MockClient((request) async { + expect(jsonDecode(request.body)['selectedRequestId'], 'leave-1'); + return http.Response( + jsonEncode({ + 'requiresSelection': false, + 'answer': '正在主管审批', + 'request': { + 'id': 'leave-1', + 'type': 'ANNUAL', + 'status': 'PENDING', + 'startsAt': '2026-07-20T01:00:00Z', + 'endsAt': '2026-07-20T09:00:00Z', + 'createdAt': '2026-07-18T01:00:00Z', + }, + 'progress': { + 'activeTaskNames': ['主管审批'], + 'completedTaskNames': [], + 'processEnded': false, + }, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + final result = await repository.ask('进度?', selectedRequestId: 'leave-1'); + expect(result.activeTasks, ['主管审批']); + expect(result.answer, '正在主管审批'); + }); +} diff --git a/mobile/test/features/form/ai_leave_suggestion_repository_test.dart b/mobile/test/features/form/ai_leave_suggestion_repository_test.dart new file mode 100644 index 0000000..d75cc32 --- /dev/null +++ b/mobile/test/features/form/ai_leave_suggestion_repository_test.dart @@ -0,0 +1,60 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/form/data/ai_leave_suggestion_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test('parses a confirmation-required Qwen suggestion', () async { + final repository = AiLeaveSuggestionRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient( + (request) async => http.Response( + jsonEncode({ + 'suggestion': { + 'type': 'PERSONAL', + 'startsAt': '2026-07-19T05:30:00Z', + 'endsAt': '2026-07-19T09:30:00Z', + 'reason': '办理个人事务', + 'assumptions': ['下午按 13:30 开始'], + 'needsClarification': [], + }, + 'model': 'qwen-plus', + 'requiresUserConfirmation': true, + }), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ), + ); + + final suggestion = await repository.suggest('明天下午请事假四小时'); + + expect(suggestion.values['type'], 'PERSONAL'); + expect(suggestion.assumptions, ['下午按 13:30 开始']); + expect(suggestion.model, 'qwen-plus'); + }); + + test('rejects AI responses that bypass user confirmation', () async { + final repository = AiLeaveSuggestionRepository( + accessToken: 'token', + client: MockClient( + (_) async => http.Response( + jsonEncode({ + 'suggestion': {'assumptions': [], 'needsClarification': []}, + 'model': 'qwen-plus', + 'requiresUserConfirmation': false, + }), + 200, + ), + ), + ); + + await expectLater( + repository.suggest('请假'), + throwsA(isA()), + ); + }); +} diff --git a/mobile/test/features/form/form_definition_repository_test.dart b/mobile/test/features/form/form_definition_repository_test.dart new file mode 100644 index 0000000..c365d96 --- /dev/null +++ b/mobile/test/features/form/form_definition_repository_test.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/form/data/form_definition_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + const responseBody = { + 'key': 'leave-request', + 'version': 1, + 'dataSchema': { + r'$id': 'leave-request-v1', + 'title': '服务端请假申请', + 'required': ['reason'], + 'properties': { + 'reason': {'type': 'string', 'minLength': 1}, + }, + }, + 'uiSchema': { + 'description': '服务端定义', + 'sections': [ + { + 'title': '说明', + 'controls': [ + {'field': 'reason', 'label': '原因', 'control': 'textArea'}, + ], + }, + ], + }, + }; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('loads remote definition with bearer token and stores cache', () async { + late http.Request capturedRequest; + final repository = FormDefinitionRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'test-token', + client: MockClient((request) async { + capturedRequest = request; + return http.Response( + jsonEncode(responseBody), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + + final loaded = await repository.loadLeaveRequest(); + + expect(loaded.source, FormDefinitionSource.remote); + expect(loaded.definition.dataSchema.title, '服务端请假申请'); + expect(capturedRequest.headers['Authorization'], 'Bearer test-token'); + final preferences = await SharedPreferences.getInstance(); + expect( + preferences.getString('form-definition.leave-request.v1'), + isNotNull, + ); + }); + + test('uses last valid cache when the network is unavailable', () async { + SharedPreferences.setMockInitialValues({ + 'form-definition.leave-request.v1': jsonEncode(responseBody), + }); + final repository = FormDefinitionRepository( + baseUrl: 'https://api.example.test/api/v1', + client: MockClient((_) async => throw http.ClientException('offline')), + ); + + final loaded = await repository.loadLeaveRequest(); + + expect(loaded.source, FormDefinitionSource.cache); + expect(loaded.definition.uiSchema.description, '服务端定义'); + }); +} diff --git a/mobile/test/features/form/leave_attachment_repository_test.dart b/mobile/test/features/form/leave_attachment_repository_test.dart new file mode 100644 index 0000000..8e09705 --- /dev/null +++ b/mobile/test/features/form/leave_attachment_repository_test.dart @@ -0,0 +1,88 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:aioa_mobile/features/form/data/leave_attachment_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + test( + 'creates task, streams bytes to MinIO and completes attachment', + () async { + final requests = []; + final repository = LeaveAttachmentRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + requests.add(request); + if (request.url.host == 'minio.test') { + return http.Response('', 200); + } + if (request.url.path.endsWith('/upload-tasks')) { + return http.Response( + jsonEncode({ + 'attachment': { + 'id': 'attachment-1', + 'fileName': 'proof.pdf', + 'contentType': 'application/pdf', + 'sizeBytes': 4, + 'status': 'PENDING', + }, + 'uploadUrl': 'https://minio.test/upload/object', + }), + 201, + ); + } + return http.Response( + jsonEncode({ + 'id': 'attachment-1', + 'fileName': 'proof.pdf', + 'contentType': 'application/pdf', + 'sizeBytes': 4, + 'status': 'READY', + }), + 200, + ); + }), + ); + final progress = []; + + final attachment = await repository.upload( + leaveRequestId: 'leave-1', + fileName: 'proof.pdf', + contentType: 'application/pdf', + bytes: Uint8List.fromList([1, 2, 3, 4]), + onProgress: progress.add, + ); + + expect(attachment.status, 'READY'); + expect(requests.map((request) => request.method), [ + 'POST', + 'PUT', + 'POST', + ]); + expect(requests[1].bodyBytes, [1, 2, 3, 4]); + expect(requests[1].headers['Content-Type'], 'application/pdf'); + expect(progress.last, 1); + }, + ); + + test('rejects oversized files before creating an upload task', () async { + final repository = LeaveAttachmentRepository( + accessToken: 'token', + client: MockClient((_) async => fail('request should not be sent')), + ); + + await expectLater( + repository.upload( + leaveRequestId: 'leave-1', + fileName: 'large.pdf', + contentType: 'application/pdf', + bytes: Uint8List(10 * 1024 * 1024 + 1), + onProgress: (_) {}, + ), + throwsA(isA()), + ); + }); +} diff --git a/mobile/test/features/form/leave_draft_submission_repository_test.dart b/mobile/test/features/form/leave_draft_submission_repository_test.dart new file mode 100644 index 0000000..89a1689 --- /dev/null +++ b/mobile/test/features/form/leave_draft_submission_repository_test.dart @@ -0,0 +1,99 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/form/data/leave_draft_submission_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + const values = { + 'type': 'PERSONAL', + 'startsAt': '2026-07-20T01:00:00Z', + 'endsAt': '2026-07-20T05:00:00Z', + 'reason': '办理个人事务', + }; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('creates a backend draft and clears the pending request', () async { + late http.Request captured; + final repository = LeaveDraftSubmissionRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + captured = request; + return http.Response( + jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}), + 201, + ); + }), + ); + + final created = await repository.create(values); + + expect(created.id, 'draft-1'); + expect(captured.headers['Authorization'], 'Bearer token'); + expect(captured.headers['Idempotency-Key']!.length, greaterThan(16)); + final body = jsonDecode(captured.body) as Map; + expect(body['version'], 0); + final preferences = await SharedPreferences.getInstance(); + expect( + preferences.getString(LeaveDraftSubmissionRepository.pendingStorageKey), + isNull, + ); + }); + + test('reuses the same idempotency key after a network failure', () async { + final keys = []; + var attempts = 0; + final repository = LeaveDraftSubmissionRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + keys.add(request.headers['Idempotency-Key']!); + attempts += 1; + if (attempts == 1) throw http.ClientException('offline'); + return http.Response( + jsonEncode({'id': 'draft-1', 'status': 'DRAFT', 'version': 0}), + 201, + ); + }), + ); + + await expectLater( + repository.create(values), + throwsA(isA()), + ); + final created = await repository.create(values); + + expect(created.id, 'draft-1'); + expect(keys, hasLength(2)); + expect(keys[1], keys[0]); + }); + + test( + 'surfaces an unauthorized response when no session token is attached', + () async { + final repository = LeaveDraftSubmissionRepository( + client: MockClient( + (_) async => + http.Response(jsonEncode({'detail': 'Unauthorized'}), 401), + ), + ); + + await expectLater( + repository.create(values), + throwsA( + isA().having( + (error) => error.retryable, + 'retryable', + isTrue, + ), + ), + ); + }, + ); +} diff --git a/mobile/test/features/form/leave_local_draft_repository_test.dart b/mobile/test/features/form/leave_local_draft_repository_test.dart new file mode 100644 index 0000000..05553b7 --- /dev/null +++ b/mobile/test/features/form/leave_local_draft_repository_test.dart @@ -0,0 +1,45 @@ +import 'package:aioa_mobile/features/form/data/leave_local_draft_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('persists and restores an unfinished leave draft', () async { + final repository = LeaveLocalDraftRepository(); + await repository.save({ + 'type': 'SICK', + 'startsAt': '2026-07-20T01:00:00Z', + 'reason': '身体不适', + }); + + final restored = await repository.load(); + + expect(restored, isNotNull); + expect(restored!.values['type'], 'SICK'); + expect(restored.values['reason'], '身体不适'); + expect(restored.savedAt.isUtc, isTrue); + }); + + test('removes corrupted local drafts instead of crashing the form', () async { + SharedPreferences.setMockInitialValues({ + LeaveLocalDraftRepository.storageKey: '{broken-json', + }); + final repository = LeaveLocalDraftRepository(); + + expect(await repository.load(), isNull); + final preferences = await SharedPreferences.getInstance(); + expect(preferences.getString(LeaveLocalDraftRepository.storageKey), isNull); + }); + + test('clears a completed or discarded draft', () async { + final repository = LeaveLocalDraftRepository(); + await repository.save({'reason': 'temporary'}); + + await repository.clear(); + + expect(await repository.load(), isNull); + }); +} diff --git a/mobile/test/features/requests/leave_request_repository_test.dart b/mobile/test/features/requests/leave_request_repository_test.dart new file mode 100644 index 0000000..9953d8d --- /dev/null +++ b/mobile/test/features/requests/leave_request_repository_test.dart @@ -0,0 +1,80 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/requests/data/leave_request_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final requestJson = { + 'id': 'leave-1', + 'type': 'PERSONAL', + 'startsAt': '2026-07-20T01:00:00Z', + 'endsAt': '2026-07-20T05:00:00Z', + 'reason': '办理个人事务', + 'status': 'DRAFT', + 'version': 0, + 'createdAt': '2026-07-18T08:00:00Z', + 'updatedAt': '2026-07-18T08:00:00Z', + }; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('loads own leave requests', () async { + final repository = LeaveRequestRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient( + (_) async => http.Response( + jsonEncode([requestJson]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ), + ); + + final requests = await repository.list(); + + expect(requests.single.status, 'DRAFT'); + expect(requests.single.reason, '办理个人事务'); + }); + + test('reuses transition idempotency key after network failure', () async { + final keys = []; + var attempt = 0; + final repository = LeaveRequestRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + keys.add( + request.headers.entries + .firstWhere( + (entry) => entry.key.toLowerCase() == 'idempotency-key', + orElse: () => const MapEntry('', 'missing'), + ) + .value, + ); + attempt += 1; + if (attempt == 1) throw http.ClientException('offline'); + return http.Response( + jsonEncode({...requestJson, 'status': 'PENDING', 'version': 1}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + final request = LeaveRequestItem.fromJson(requestJson); + + await expectLater( + repository.transition(request, 'submit'), + throwsA(isA()), + ); + final submitted = await repository.transition(request, 'submit'); + + expect(submitted.status, 'PENDING'); + expect(keys[1], keys[0]); + }); +} diff --git a/mobile/test/features/tasks/approval_task_repository_test.dart b/mobile/test/features/tasks/approval_task_repository_test.dart new file mode 100644 index 0000000..908fbfc --- /dev/null +++ b/mobile/test/features/tasks/approval_task_repository_test.dart @@ -0,0 +1,83 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/tasks/data/approval_task_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + final taskJson = { + 'id': 'task-1', + 'name': '部门主管审批', + 'createdAt': '2026-07-18T08:00:00Z', + 'leaveRequest': { + 'id': 'leave-1', + 'type': 'PERSONAL', + 'startsAt': '2026-07-20T01:00:00Z', + 'endsAt': '2026-07-20T05:00:00Z', + 'reason': '办理个人事务', + 'status': 'PENDING', + 'version': 1, + }, + }; + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('loads assigned approval tasks', () async { + final repository = ApprovalTaskRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient( + (_) async => http.Response( + jsonEncode([taskJson]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ), + ); + + final tasks = await repository.list(); + + expect(tasks.single.name, '部门主管审批'); + expect(tasks.single.leaveRequest.version, 1); + }); + + test('reuses approval idempotency key after network failure', () async { + final keys = []; + var attempt = 0; + final repository = ApprovalTaskRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + keys.add( + request.headers.entries + .firstWhere( + (entry) => entry.key.toLowerCase() == 'idempotency-key', + orElse: () => const MapEntry('', 'missing'), + ) + .value, + ); + attempt += 1; + if (attempt == 1) throw http.ClientException('offline'); + return http.Response( + jsonEncode(taskJson['leaveRequest']), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + final task = ApprovalTaskItem.fromJson(taskJson); + + await expectLater( + repository.decide(task: task, approved: true, comment: '同意'), + throwsA(isA()), + ); + await repository.decide(task: task, approved: true, comment: '同意'); + + expect(keys, hasLength(2)); + expect(keys[1], keys[0]); + }); +} diff --git a/mobile/test/features/tasks/notification_repository_test.dart b/mobile/test/features/tasks/notification_repository_test.dart new file mode 100644 index 0000000..3b67bdb --- /dev/null +++ b/mobile/test/features/tasks/notification_repository_test.dart @@ -0,0 +1,59 @@ +import 'dart:convert'; + +import 'package:aioa_mobile/features/tasks/data/notification_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; + +void main() { + final unreadJson = { + 'id': 'notification-1', + 'type': 'LEAVE_APPROVED', + 'title': '请假申请已通过', + 'body': '你的请假申请已完成审批。', + 'resourceType': 'LEAVE_REQUEST', + 'resourceId': 'leave-1', + 'createdAt': '2026-07-18T08:00:00Z', + 'readAt': null, + }; + + test('loads own notifications with bearer token', () async { + late http.Request captured; + final repository = NotificationRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + captured = request; + return http.Response( + jsonEncode([unreadJson]), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + + final notifications = await repository.list(); + + expect(notifications.single.isRead, isFalse); + expect(notifications.single.title, '请假申请已通过'); + expect(captured.headers['Authorization'], 'Bearer token'); + }); + + test('marks a notification read', () async { + final repository = NotificationRepository( + baseUrl: 'https://api.example.test/api/v1', + accessToken: 'token', + client: MockClient((request) async { + return http.Response( + jsonEncode({...unreadJson, 'readAt': '2026-07-18T08:05:00Z'}), + 200, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }), + ); + + final notification = await repository.markRead('notification-1'); + + expect(notification.isRead, isTrue); + }); +} diff --git a/scripts/README.md b/scripts/README.md index ac82058..e34167f 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # Scripts -后续放置开发环境检查、契约生成、数据库迁移和本地启动脚本。脚本必须可重复运行,不得包含密钥。 +开发环境检查、契约验证、数据库迁移和本地启动脚本放在此目录。脚本必须可重复运行,不得包含生产密钥。 -`verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,验证健康检查、Token 获取和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。 +`verify-local-auth.sh` 使用 Keycloak Realm 中明确标记为仅限本地开发的账号,注册脚本设备并验证健康检查、Token 获取、设备会话和 `/api/v1/me`。不得把该脚本及其测试凭据用于共享或生产环境。 + +`verify-all.sh` 执行后端测试、AI 服务测试、Flutter 格式/分析/测试、OpenAPI YAML 和 Git 空白检查。设置 `AIOA_FULL_BUILD=1` 后还会构建 Android Debug APK,并在 macOS 上构建 iOS Simulator App。 diff --git a/scripts/dev-android-reverse.sh b/scripts/dev-android-reverse.sh new file mode 100755 index 0000000..d93645b --- /dev/null +++ b/scripts/dev-android-reverse.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +set -euo pipefail + +adb reverse tcp:8080 tcp:8080 +adb reverse tcp:8081 tcp:8081 +adb reverse tcp:9000 tcp:9000 + +echo "Android emulator reverse ports configured for AIOA API, Keycloak, and MinIO." diff --git a/scripts/verify-all.sh b/scripts/verify-all.sh new file mode 100755 index 0000000..e20dc52 --- /dev/null +++ b/scripts/verify-all.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash + +set -euo pipefail + +root_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +java_home_default='/usr/local/opt/openjdk@21/libexec/openjdk.jdk/Contents/Home' + +if [[ -z "${JAVA_HOME:-}" && -d "${java_home_default}" ]]; then + export JAVA_HOME="${java_home_default}" +fi + +if [[ -z "${JAVA_HOME:-}" ]]; then + echo 'JDK 21 is required. Set JAVA_HOME before running verification.' >&2 + exit 1 +fi + +echo '[1/5] Backend tests' +( + cd "${root_dir}/backend" + ./gradlew --no-daemon test +) + +echo '[2/5] AI service tests' +( + cd "${root_dir}/ai-service" + if [[ -x .venv/bin/python ]]; then + .venv/bin/python -m compileall -q app tests + .venv/bin/python -m pytest + else + python3 -m compileall -q app tests + python3 -m pytest + fi +) + +echo '[3/5] Flutter formatting, analysis and tests' +( + cd "${root_dir}/mobile" + dart format --output=none --set-exit-if-changed lib test + flutter analyze + flutter test +) + +echo '[4/5] Contract and whitespace checks' +( + cd "${root_dir}" + ruby -e "require 'yaml'; YAML.load_file('contracts/openapi/aioa-v1.yaml')" + git diff --check +) + +echo '[5/5] Optional platform builds' +if [[ "${AIOA_FULL_BUILD:-0}" == '1' ]]; then + ( + cd "${root_dir}/mobile" + flutter build apk --debug + if [[ "$(uname -s)" == 'Darwin' ]]; then + flutter build ios --simulator --no-codesign + fi + ) +else + echo 'Skipped. Set AIOA_FULL_BUILD=1 to build Android and iOS simulator artifacts.' +fi + +echo 'AIOA verification completed successfully.' diff --git a/scripts/verify-local-auth.sh b/scripts/verify-local-auth.sh index c17537b..6a92501 100755 --- a/scripts/verify-local-auth.sh +++ b/scripts/verify-local-auth.sh @@ -25,8 +25,23 @@ for username in employee manager admin hr; do --data-urlencode password="${password}" } | jq -r .access_token)" + case "${username}" in + employee) device_id='10000000-0000-4000-8000-000000000001' ;; + manager) device_id='10000000-0000-4000-8000-000000000002' ;; + admin) device_id='10000000-0000-4000-8000-000000000003' ;; + hr) device_id='10000000-0000-4000-8000-000000000004' ;; + esac + + curl --fail --silent --show-error \ + -X POST "${backend_url}/api/v1/devices/register" \ + -H "Authorization: Bearer ${token}" \ + -H 'Content-Type: application/json' \ + --data "{\"id\":\"${device_id}\",\"name\":\"Local auth verifier (${username})\",\"platform\":\"OTHER\",\"appVersion\":\"script\"}" \ + >/dev/null + printf '%s\n' "${username}" curl --fail --silent --show-error \ "${backend_url}/api/v1/me" \ - -H "Authorization: Bearer ${token}" | jq . + -H "Authorization: Bearer ${token}" \ + -H "X-AIOA-Device-Id: ${device_id}" | jq . done