R18-1/R18-3: 接入企业微信入口绑定与免登回退
这个提交包含在:
@@ -20,6 +20,32 @@ export async function changePassword(payload: { oldPassword: string; newPassword
|
||||
|
||||
export type SsoProviderInfo = { id: "oidc"; label: string; enabled: boolean; loginUrl: string };
|
||||
|
||||
export type WecomEntryConfig = {
|
||||
enabled: boolean;
|
||||
corpId: string;
|
||||
agentId: string;
|
||||
oauthEnabled: boolean;
|
||||
loginUrl: string | null;
|
||||
};
|
||||
|
||||
export async function fetchSsoProviders() {
|
||||
return apiRequestData<SsoProviderInfo[]>("/api/auth/sso/providers", { auth: false });
|
||||
}
|
||||
|
||||
export async function fetchWecomConfig() {
|
||||
return apiRequestData<WecomEntryConfig>("/api/auth/wecom/config", { auth: false });
|
||||
}
|
||||
|
||||
export async function startWecomBind(returnTo = "/settings/profile") {
|
||||
return apiRequestData<{ loginUrl: string }>("/api/auth/wecom/bind", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ returnTo }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function unbindWecom() {
|
||||
return apiRequestData<{ ok: boolean }>("/api/auth/wecom/unbind", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
}
|
||||
|
||||
+33
-12
@@ -1,7 +1,7 @@
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useNavigate, useSearchParams } from "react-router-dom";
|
||||
import { fetchSsoProviders } from "../api/auth";
|
||||
import { fetchSsoProviders, fetchWecomConfig } from "../api/auth";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
@@ -39,6 +39,12 @@ export function LoginPage() {
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const oidcProvider = (ssoProvidersQuery.data ?? []).find((item) => item.id === "oidc" && item.enabled);
|
||||
const wecomConfigQuery = useQuery({
|
||||
queryKey: ["auth", "wecom-config"],
|
||||
queryFn: () => fetchWecomConfig(),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const wecomEntry = wecomConfigQuery.data?.enabled ? wecomConfigQuery.data : null;
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
@@ -114,19 +120,34 @@ export function LoginPage() {
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
{oidcProvider ? (
|
||||
{oidcProvider || wecomEntry ? (
|
||||
<div className="space-y-3 border-t border-zinc-200 pt-4">
|
||||
<p className="text-xs uppercase tracking-wide text-zinc-500">企业统一入口</p>
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
{wecomEntry?.loginUrl ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const loginUrl = wecomEntry.loginUrl ?? "/api/auth/wecom/login";
|
||||
window.location.assign(loginUrl.replace(/returnTo=%2Fportal$/, `returnTo=${encodeURIComponent(redirectTo)}`));
|
||||
}}
|
||||
>
|
||||
使用企业微信进入工作台
|
||||
</Button>
|
||||
) : null}
|
||||
{oidcProvider ? (
|
||||
<Button
|
||||
className="w-full"
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
window.location.assign(`${oidcProvider.loginUrl}?returnTo=${encodeURIComponent(redirectTo)}`);
|
||||
}}
|
||||
>
|
||||
使用{oidcProvider.label}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { fetchWecomConfig, startWecomBind, unbindWecom } from "../api/auth";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { Button } from "../components/ui/button";
|
||||
|
||||
export function ProfilePage() {
|
||||
const { me, permissions } = useAuth();
|
||||
const [searchParams] = useSearchParams();
|
||||
const wecomConfigQuery = useQuery({ queryKey: ["auth", "wecom-config"], queryFn: () => fetchWecomConfig(), staleTime: 60_000 });
|
||||
const bindMutation = useMutation({
|
||||
mutationFn: async () => startWecomBind("/settings/profile"),
|
||||
onSuccess: (payload) => { window.location.assign(payload.loginUrl); },
|
||||
});
|
||||
const unbindMutation = useMutation({
|
||||
mutationFn: () => unbindWecom(),
|
||||
onSuccess: () => { window.location.reload(); },
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -38,6 +52,21 @@ export function ProfilePage() {
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section className="card-std mt-4">
|
||||
<h3 className="card-title">企业微信绑定</h3>
|
||||
<div className="space-y-3 text-sm text-zinc-700">
|
||||
<div>接入状态:<span className="font-medium text-zinc-900">{wecomConfigQuery.data?.enabled ? "已启用" : "未启用"}</span></div>
|
||||
<div>绑定状态:<span className="font-medium text-zinc-900">{me?.wecomBinding?.externalUserId ?? "未绑定"}</span></div>
|
||||
{searchParams.get("wecomBind") ? (
|
||||
<div className="rounded-lg border border-zinc-200 bg-zinc-50 px-3 py-2">绑定结果:{searchParams.get("wecomBind")}</div>
|
||||
) : null}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button type="button" variant="secondary" disabled={!wecomConfigQuery.data?.enabled || bindMutation.isPending} onClick={() => bindMutation.mutate()}>绑定企业微信</Button>
|
||||
<Button type="button" variant="secondary" disabled={!me?.wecomBinding || unbindMutation.isPending} onClick={() => unbindMutation.mutate()}>解绑企业微信</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="card-std mt-4">
|
||||
<h3 className="card-title">权限列表</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
|
||||
@@ -35,6 +35,9 @@ import {
|
||||
PeoplePage,
|
||||
PortalAnnouncementsPage,
|
||||
PortalHomePage,
|
||||
ProjectsPage,
|
||||
ProjectDetailPage,
|
||||
ContractsRegistryPage,
|
||||
SystemSettingsPage,
|
||||
TeamPresencePage,
|
||||
TodosPage,
|
||||
@@ -92,6 +95,30 @@ export function AppRouter() {
|
||||
<Route path="portal" element={withRouteFallback(<PortalHomePage />)} />
|
||||
<Route path="announcements" element={withRouteFallback(<AnnouncementsPage />)} />
|
||||
<Route path="team" element={withRouteFallback(<TeamPresencePage />)} />
|
||||
<Route
|
||||
path="projects"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ProjectsPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="projects/:projectId"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ProjectDetailPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contracts-registry"
|
||||
element={
|
||||
<RequirePermission anyOf={["project.view_own", "project.view_all", "project.manage", "system.settings"]}>
|
||||
{withRouteFallback(<ContractsRegistryPage />)}
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
<Route path="weekly-reports" element={withRouteFallback(<WeeklyReportsPage />)} />
|
||||
<Route path="todos" element={withRouteFallback(<TodosPage />)} />
|
||||
<Route path="dashboard" element={<DashboardPage />} />
|
||||
|
||||
@@ -23,6 +23,18 @@ FIELD_ENCRYPTION_KEY=""
|
||||
# OIDC_REDIRECT_URI="http://127.0.0.1:3000/api/auth/oidc/callback"
|
||||
# OIDC_SCOPES="openid profile email"
|
||||
|
||||
# ===== 企业微信入口 / OAuth(R18,可选) =====
|
||||
WECOM_ENABLED="false"
|
||||
WECOM_ENTRY_ENABLED="false"
|
||||
WECOM_CORP_ID=""
|
||||
WECOM_AGENT_ID=""
|
||||
WECOM_AGENT_SECRET=""
|
||||
WECOM_OAUTH_ENABLED="false"
|
||||
WECOM_REDIRECT_URI="https://oa.example.com/api/auth/wecom/callback"
|
||||
# 可填写多个 host,逗号分隔,用于企微回跳和公网 returnTo 白名单
|
||||
WECOM_ALLOWED_RETURN_HOSTS="oa.example.com"
|
||||
IM_NOTIFIER_ENABLED="true"
|
||||
|
||||
# ===== 数据库(Compose 内部连接)=====
|
||||
POSTGRES_DB="hyb_oa"
|
||||
POSTGRES_USER="hyboa_user"
|
||||
@@ -49,6 +61,7 @@ MINIO_ROOT_PASSWORD="minioadmin"
|
||||
# ===== 单域托管 client/dist =====
|
||||
SERVE_CLIENT_DIST="true"
|
||||
CLIENT_DIST_DIR="../client/dist"
|
||||
OPENAPI_DOCS_ENABLED="false"
|
||||
|
||||
# ===== 网络与端口 =====
|
||||
HOST="0.0.0.0"
|
||||
@@ -106,5 +119,9 @@ PRESENCE_BOARD_ENABLED="true"
|
||||
WEEKLY_REPORT_ENABLED="true"
|
||||
EMPLOYEE_LIFECYCLE_ENABLED="true"
|
||||
TODO_BOARD_ENABLED="true"
|
||||
PROJECT_DOMAIN_ENABLED="true"
|
||||
PROJECT_LINKING_ENABLED="true"
|
||||
CONFIDENTIAL_DOC_REFS_ENABLED="true"
|
||||
PUBLIC_SECURITY_HARDENING_ENABLED="true"
|
||||
PAYMENT_BATCH_ENABLED="false"
|
||||
CUTOVER_CONSOLE_ENABLED="false"
|
||||
|
||||
@@ -0,0 +1,357 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectStatus" AS ENUM ('draft', 'active', 'paused', 'completed', 'archived');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectRole" AS ENUM ('owner', 'manager', 'member', 'observer');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectMilestoneStatus" AS ENUM ('pending', 'in_progress', 'done', 'delayed', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectTaskStatus" AS ENUM ('todo', 'doing', 'blocked', 'done', 'cancelled');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectTaskPriority" AS ENUM ('low', 'normal', 'high', 'urgent');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectRiskLevel" AS ENUM ('low', 'medium', 'high');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ProjectDeliveryRecordType" AS ENUM ('kickoff', 'onsite', 'stage_acceptance', 'final_acceptance', 'handover');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "ConfidentialityLevel" AS ENUM ('public', 'internal', 'confidential', 'restricted');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "DocumentStorageType" AS ENUM ('oa_link', 'nas', 'internal_share', 'offline_archive');
|
||||
|
||||
-- AlterEnum
|
||||
ALTER TYPE "WorkspacePrimaryEntryMode" ADD VALUE 'wecom';
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "reimbursement_forms" ADD COLUMN "project_id" UUID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "todo_items" ADD COLUMN "project_id" UUID;
|
||||
|
||||
-- AlterTable
|
||||
ALTER TABLE "travel_requests" ADD COLUMN "project_id" UUID;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "wecom_identity_bindings" (
|
||||
"id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"external_user_id" VARCHAR(128) NOT NULL,
|
||||
"external_open_user_id" VARCHAR(128),
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"created_by" UUID,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_by" UUID,
|
||||
|
||||
CONSTRAINT "wecom_identity_bindings_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "projects" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_no" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"client_name" TEXT NOT NULL,
|
||||
"client_alias" TEXT,
|
||||
"owner_employee_id" UUID NOT NULL,
|
||||
"manager_employee_id" UUID,
|
||||
"status" "ProjectStatus" NOT NULL DEFAULT 'draft',
|
||||
"starts_at" DATE,
|
||||
"target_end_at" DATE,
|
||||
"actual_end_at" DATE,
|
||||
"summary" TEXT,
|
||||
"is_confidential" BOOLEAN NOT NULL DEFAULT false,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "projects_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "project_members" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"employee_id" UUID NOT NULL,
|
||||
"role" "ProjectRole" NOT NULL DEFAULT 'member',
|
||||
"joined_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"left_at" TIMESTAMP(3),
|
||||
|
||||
CONSTRAINT "project_members_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "project_milestones" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "ProjectMilestoneStatus" NOT NULL DEFAULT 'pending',
|
||||
"due_date" DATE,
|
||||
"completed_at" TIMESTAMP(3),
|
||||
"owner_employee_id" UUID,
|
||||
"sort_order" INTEGER NOT NULL DEFAULT 100,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_milestones_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "project_tasks" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"description" TEXT,
|
||||
"status" "ProjectTaskStatus" NOT NULL DEFAULT 'todo',
|
||||
"priority" "ProjectTaskPriority" NOT NULL DEFAULT 'normal',
|
||||
"assignee_employee_id" UUID,
|
||||
"due_date" DATE,
|
||||
"blocked_reason" TEXT,
|
||||
"created_by" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "project_risks" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"detail" TEXT NOT NULL,
|
||||
"level" "ProjectRiskLevel" NOT NULL DEFAULT 'medium',
|
||||
"status" VARCHAR(32) NOT NULL DEFAULT 'open',
|
||||
"owner_employee_id" UUID,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_risks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "weekly_report_project_links" (
|
||||
"id" UUID NOT NULL,
|
||||
"weekly_report_id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"progress_text" TEXT NOT NULL DEFAULT '',
|
||||
"risk_text" TEXT NOT NULL DEFAULT '',
|
||||
"next_step_text" TEXT NOT NULL DEFAULT '',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "weekly_report_project_links_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "contract_registry" (
|
||||
"id" UUID NOT NULL,
|
||||
"contract_no" TEXT NOT NULL,
|
||||
"project_id" UUID,
|
||||
"counterparty_name" TEXT NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"confidentiality_level" "ConfidentialityLevel" NOT NULL DEFAULT 'internal',
|
||||
"archive_location" TEXT NOT NULL,
|
||||
"offline_archive_no" TEXT,
|
||||
"owner_employee_id" UUID NOT NULL,
|
||||
"status" VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "contract_registry_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "project_delivery_records" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID NOT NULL,
|
||||
"title" TEXT NOT NULL,
|
||||
"record_type" "ProjectDeliveryRecordType" NOT NULL,
|
||||
"summary" TEXT NOT NULL,
|
||||
"occurred_at" TIMESTAMP(3) NOT NULL,
|
||||
"owner_employee_id" UUID NOT NULL,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_delivery_records_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "confidential_document_refs" (
|
||||
"id" UUID NOT NULL,
|
||||
"project_id" UUID,
|
||||
"contract_registry_id" UUID,
|
||||
"title" TEXT NOT NULL,
|
||||
"document_no" TEXT,
|
||||
"confidentiality_level" "ConfidentialityLevel" NOT NULL DEFAULT 'internal',
|
||||
"storage_type" "DocumentStorageType" NOT NULL,
|
||||
"storage_path" TEXT NOT NULL,
|
||||
"offline_archive_no" TEXT,
|
||||
"owner_employee_id" UUID NOT NULL,
|
||||
"access_rule_json" JSONB,
|
||||
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "confidential_document_refs_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wecom_identity_bindings_employee_id_key" ON "wecom_identity_bindings"("employee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "wecom_identity_bindings_external_user_id_key" ON "wecom_identity_bindings"("external_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_wecom_identity_bindings_external_user" ON "wecom_identity_bindings"("external_user_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "projects_project_no_key" ON "projects"("project_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_projects_owner_status" ON "projects"("owner_employee_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_projects_manager_status" ON "projects"("manager_employee_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_projects_status_target_end" ON "projects"("status", "target_end_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_projects_client_name" ON "projects"("client_name", "name");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_members_employee_open" ON "project_members"("employee_id", "left_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "project_members_project_employee_ux" ON "project_members"("project_id", "employee_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_milestones_project_sort" ON "project_milestones"("project_id", "sort_order");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_milestones_owner_due" ON "project_milestones"("owner_employee_id", "due_date");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_tasks_project_status_priority" ON "project_tasks"("project_id", "status", "priority");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_tasks_assignee_status" ON "project_tasks"("assignee_employee_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_risks_project_status_level" ON "project_risks"("project_id", "status", "level");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_risks_owner_status" ON "project_risks"("owner_employee_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_weekly_report_project_links_project_updated" ON "weekly_report_project_links"("project_id", "updated_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "weekly_report_project_links_report_project_ux" ON "weekly_report_project_links"("weekly_report_id", "project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "contract_registry_contract_no_key" ON "contract_registry"("contract_no");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_contract_registry_project_status" ON "contract_registry"("project_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_contract_registry_owner_created" ON "contract_registry"("owner_employee_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_project_delivery_records_project_occurred" ON "project_delivery_records"("project_id", "occurred_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_confidential_doc_refs_project_level" ON "confidential_document_refs"("project_id", "confidentiality_level");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_confidential_doc_refs_contract" ON "confidential_document_refs"("contract_registry_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_confidential_doc_refs_owner_created" ON "confidential_document_refs"("owner_employee_id", "created_at");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_reimbursement_forms_project" ON "reimbursement_forms"("project_id");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_todo_items_project_status" ON "todo_items"("project_id", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "idx_travel_requests_project" ON "travel_requests"("project_id");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "reimbursement_forms" ADD CONSTRAINT "reimbursement_forms_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "todo_items" ADD CONSTRAINT "todo_items_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "wecom_identity_bindings" ADD CONSTRAINT "wecom_identity_bindings_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "projects" ADD CONSTRAINT "projects_manager_employee_id_fkey" FOREIGN KEY ("manager_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_members" ADD CONSTRAINT "project_members_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_members" ADD CONSTRAINT "project_members_employee_id_fkey" FOREIGN KEY ("employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_milestones" ADD CONSTRAINT "project_milestones_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_milestones" ADD CONSTRAINT "project_milestones_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_tasks" ADD CONSTRAINT "project_tasks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_tasks" ADD CONSTRAINT "project_tasks_assignee_employee_id_fkey" FOREIGN KEY ("assignee_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_tasks" ADD CONSTRAINT "project_tasks_created_by_fkey" FOREIGN KEY ("created_by") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_risks" ADD CONSTRAINT "project_risks_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_risks" ADD CONSTRAINT "project_risks_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "weekly_report_project_links" ADD CONSTRAINT "weekly_report_project_links_weekly_report_id_fkey" FOREIGN KEY ("weekly_report_id") REFERENCES "weekly_reports"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "weekly_report_project_links" ADD CONSTRAINT "weekly_report_project_links_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contract_registry" ADD CONSTRAINT "contract_registry_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "contract_registry" ADD CONSTRAINT "contract_registry_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_delivery_records" ADD CONSTRAINT "project_delivery_records_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "project_delivery_records" ADD CONSTRAINT "project_delivery_records_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "confidential_document_refs" ADD CONSTRAINT "confidential_document_refs_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "confidential_document_refs" ADD CONSTRAINT "confidential_document_refs_contract_registry_id_fkey" FOREIGN KEY ("contract_registry_id") REFERENCES "contract_registry"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "confidential_document_refs" ADD CONSTRAINT "confidential_document_refs_owner_employee_id_fkey" FOREIGN KEY ("owner_employee_id") REFERENCES "employees"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "travel_requests" ADD CONSTRAINT "travel_requests_project_id_fkey" FOREIGN KEY ("project_id") REFERENCES "projects"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
+323
-10
@@ -275,6 +275,7 @@ enum WorkspaceOrgMode {
|
||||
|
||||
enum WorkspacePrimaryEntryMode {
|
||||
password
|
||||
wecom
|
||||
}
|
||||
|
||||
enum WorkspaceNotificationStrategy {
|
||||
@@ -364,6 +365,72 @@ enum TodoSource {
|
||||
weekly_report
|
||||
}
|
||||
|
||||
enum ProjectStatus {
|
||||
draft
|
||||
active
|
||||
paused
|
||||
completed
|
||||
archived
|
||||
}
|
||||
|
||||
enum ProjectRole {
|
||||
owner
|
||||
manager
|
||||
member
|
||||
observer
|
||||
}
|
||||
|
||||
enum ProjectMilestoneStatus {
|
||||
pending
|
||||
in_progress
|
||||
done
|
||||
delayed
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum ProjectTaskStatus {
|
||||
todo
|
||||
doing
|
||||
blocked
|
||||
done
|
||||
cancelled
|
||||
}
|
||||
|
||||
enum ProjectTaskPriority {
|
||||
low
|
||||
normal
|
||||
high
|
||||
urgent
|
||||
}
|
||||
|
||||
enum ProjectRiskLevel {
|
||||
low
|
||||
medium
|
||||
high
|
||||
}
|
||||
|
||||
enum ProjectDeliveryRecordType {
|
||||
kickoff
|
||||
onsite
|
||||
stage_acceptance
|
||||
final_acceptance
|
||||
handover
|
||||
}
|
||||
|
||||
enum ConfidentialityLevel {
|
||||
public
|
||||
internal
|
||||
confidential
|
||||
restricted
|
||||
}
|
||||
|
||||
enum DocumentStorageType {
|
||||
oa_link
|
||||
nas
|
||||
internal_share
|
||||
offline_archive
|
||||
}
|
||||
|
||||
model Department {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
name String
|
||||
@@ -479,15 +546,26 @@ model Employee {
|
||||
workflow_tasks_assigned WorkflowTask[] @relation("WorkflowTaskAssignee")
|
||||
workflow_tasks_completed WorkflowTask[] @relation("WorkflowTaskCompleter")
|
||||
|
||||
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
|
||||
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
|
||||
password_reset_codes PasswordResetCode[]
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
presence_records EmployeePresence[]
|
||||
weekly_reports WeeklyReport[]
|
||||
todo_items_assigned TodoItem[] @relation("TodoAssignee")
|
||||
todo_items_created TodoItem[] @relation("TodoCreator")
|
||||
approval_transfers_from ApprovalTransfer[] @relation("TransferFrom")
|
||||
approval_transfers_to ApprovalTransfer[] @relation("TransferTo")
|
||||
password_reset_codes PasswordResetCode[]
|
||||
lifecycle_events EmployeeLifecycleEvent[]
|
||||
checklist_tasks EmployeeChecklistTask[]
|
||||
presence_records EmployeePresence[]
|
||||
weekly_reports WeeklyReport[]
|
||||
todo_items_assigned TodoItem[] @relation("TodoAssignee")
|
||||
todo_items_created TodoItem[] @relation("TodoCreator")
|
||||
wecom_identity_binding WecomIdentityBinding?
|
||||
owned_projects Project[] @relation("ProjectOwner")
|
||||
managed_projects Project[] @relation("ProjectManager")
|
||||
project_memberships ProjectMember[]
|
||||
owned_project_milestones ProjectMilestone[] @relation("ProjectMilestoneOwner")
|
||||
assigned_project_tasks ProjectTask[] @relation("ProjectTaskAssignee")
|
||||
created_project_tasks ProjectTask[] @relation("ProjectTaskCreator")
|
||||
owned_project_risks ProjectRisk[] @relation("ProjectRiskOwner")
|
||||
owned_project_delivery_records ProjectDeliveryRecord[] @relation("ProjectDeliveryRecordOwner")
|
||||
owned_contract_registries ContractRegistry[] @relation("ContractRegistryOwner")
|
||||
owned_confidential_document_refs ConfidentialDocumentRef[] @relation("ConfidentialDocumentRefOwner")
|
||||
|
||||
@@index([department_id])
|
||||
@@index([legal_entity_id], map: "idx_employees_legal_entity")
|
||||
@@ -1052,6 +1130,7 @@ model ReimbursementForm {
|
||||
payment_channel String? @db.VarChar(20)
|
||||
paid_at DateTime?
|
||||
paid_by String? @db.Uuid
|
||||
project_id String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
@@ -1062,6 +1141,7 @@ model ReimbursementForm {
|
||||
owner Employee @relation("ReimbursementOwner", fields: [owner_id], references: [id], onDelete: Restrict)
|
||||
paid_by_employee Employee? @relation("ReimbursementPaidBy", fields: [paid_by], references: [id], onDelete: SetNull)
|
||||
department Department @relation(fields: [department_id], references: [id], onDelete: Restrict)
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
|
||||
items ReimbursementItem[]
|
||||
invoice_pool_items InvoicePool[]
|
||||
@@ -1074,6 +1154,7 @@ model ReimbursementForm {
|
||||
@@index([submitter_id])
|
||||
@@index([department_id])
|
||||
@@index([paid_by])
|
||||
@@index([project_id], map: "idx_reimbursement_forms_project")
|
||||
@@index([status])
|
||||
@@index([reimbursement_date])
|
||||
@@index([owner_id, status, updated_at], map: "idx_reimbursement_owner_status_updated_at")
|
||||
@@ -1608,7 +1689,8 @@ model WeeklyReport {
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
project_links ProjectLinkOnWeeklyReport[]
|
||||
|
||||
@@unique([employee_id, week_start], map: "weekly_reports_employee_week_start_ux")
|
||||
@@index([week_start, status], map: "idx_weekly_reports_week_status")
|
||||
@@ -1625,6 +1707,7 @@ model TodoItem {
|
||||
priority TodoPriority @default(normal)
|
||||
status TodoStatus @default(todo)
|
||||
source TodoSource @default(manual)
|
||||
project_id String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
@@ -1632,12 +1715,239 @@ model TodoItem {
|
||||
|
||||
assignee Employee @relation("TodoAssignee", fields: [assignee_employee_id], references: [id], onDelete: Restrict)
|
||||
creator Employee @relation("TodoCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([assignee_employee_id, status, due_date], map: "idx_todo_items_assignee_status_due")
|
||||
@@index([project_id, status], map: "idx_todo_items_project_status")
|
||||
@@index([created_by, created_at], map: "idx_todo_items_creator_created")
|
||||
@@map("todo_items")
|
||||
}
|
||||
|
||||
model WecomIdentityBinding {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
employee_id String @unique @db.Uuid
|
||||
external_user_id String @unique @db.VarChar(128)
|
||||
external_open_user_id String? @db.VarChar(128)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
created_by String? @db.Uuid
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
updated_by String? @db.Uuid
|
||||
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([external_user_id], map: "idx_wecom_identity_bindings_external_user")
|
||||
@@map("wecom_identity_bindings")
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_no String @unique
|
||||
name String
|
||||
client_name String
|
||||
client_alias String?
|
||||
owner_employee_id String @db.Uuid
|
||||
manager_employee_id String? @db.Uuid
|
||||
status ProjectStatus @default(draft)
|
||||
starts_at DateTime? @db.Date
|
||||
target_end_at DateTime? @db.Date
|
||||
actual_end_at DateTime? @db.Date
|
||||
summary String?
|
||||
is_confidential Boolean @default(false)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
owner Employee @relation("ProjectOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
manager Employee? @relation("ProjectManager", fields: [manager_employee_id], references: [id], onDelete: SetNull)
|
||||
members ProjectMember[]
|
||||
milestones ProjectMilestone[]
|
||||
tasks ProjectTask[]
|
||||
risks ProjectRisk[]
|
||||
reimbursements ReimbursementForm[]
|
||||
travel_requests TravelRequest[]
|
||||
todo_items TodoItem[]
|
||||
weekly_reports ProjectLinkOnWeeklyReport[]
|
||||
contracts ContractRegistry[]
|
||||
deliveries ProjectDeliveryRecord[]
|
||||
document_refs ConfidentialDocumentRef[]
|
||||
|
||||
@@index([owner_employee_id, status], map: "idx_projects_owner_status")
|
||||
@@index([manager_employee_id, status], map: "idx_projects_manager_status")
|
||||
@@index([status, target_end_at], map: "idx_projects_status_target_end")
|
||||
@@index([client_name, name], map: "idx_projects_client_name")
|
||||
@@map("projects")
|
||||
}
|
||||
|
||||
model ProjectMember {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
employee_id String @db.Uuid
|
||||
role ProjectRole @default(member)
|
||||
joined_at DateTime @default(now())
|
||||
left_at DateTime?
|
||||
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
employee Employee @relation(fields: [employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@unique([project_id, employee_id], map: "project_members_project_employee_ux")
|
||||
@@index([employee_id, left_at], map: "idx_project_members_employee_open")
|
||||
@@map("project_members")
|
||||
}
|
||||
|
||||
model ProjectMilestone {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
status ProjectMilestoneStatus @default(pending)
|
||||
due_date DateTime? @db.Date
|
||||
completed_at DateTime?
|
||||
owner_employee_id String? @db.Uuid
|
||||
sort_order Int @default(100)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
owner Employee? @relation("ProjectMilestoneOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([project_id, sort_order], map: "idx_project_milestones_project_sort")
|
||||
@@index([owner_employee_id, due_date], map: "idx_project_milestones_owner_due")
|
||||
@@map("project_milestones")
|
||||
}
|
||||
|
||||
model ProjectTask {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
title String
|
||||
description String?
|
||||
status ProjectTaskStatus @default(todo)
|
||||
priority ProjectTaskPriority @default(normal)
|
||||
assignee_employee_id String? @db.Uuid
|
||||
due_date DateTime? @db.Date
|
||||
blocked_reason String?
|
||||
created_by String @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
assignee Employee? @relation("ProjectTaskAssignee", fields: [assignee_employee_id], references: [id], onDelete: SetNull)
|
||||
creator Employee @relation("ProjectTaskCreator", fields: [created_by], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([project_id, status, priority], map: "idx_project_tasks_project_status_priority")
|
||||
@@index([assignee_employee_id, status], map: "idx_project_tasks_assignee_status")
|
||||
@@map("project_tasks")
|
||||
}
|
||||
|
||||
model ProjectRisk {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
title String
|
||||
detail String
|
||||
level ProjectRiskLevel @default(medium)
|
||||
status String @default("open") @db.VarChar(32)
|
||||
owner_employee_id String? @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
owner Employee? @relation("ProjectRiskOwner", fields: [owner_employee_id], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([project_id, status, level], map: "idx_project_risks_project_status_level")
|
||||
@@index([owner_employee_id, status], map: "idx_project_risks_owner_status")
|
||||
@@map("project_risks")
|
||||
}
|
||||
|
||||
model ProjectLinkOnWeeklyReport {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
weekly_report_id String @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
progress_text String @default("")
|
||||
risk_text String @default("")
|
||||
next_step_text String @default("")
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
weekly_report WeeklyReport @relation(fields: [weekly_report_id], references: [id], onDelete: Cascade)
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([weekly_report_id, project_id], map: "weekly_report_project_links_report_project_ux")
|
||||
@@index([project_id, updated_at], map: "idx_weekly_report_project_links_project_updated")
|
||||
@@map("weekly_report_project_links")
|
||||
}
|
||||
|
||||
model ContractRegistry {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
contract_no String @unique
|
||||
project_id String? @db.Uuid
|
||||
counterparty_name String
|
||||
summary String
|
||||
confidentiality_level ConfidentialityLevel @default(internal)
|
||||
archive_location String
|
||||
offline_archive_no String?
|
||||
owner_employee_id String @db.Uuid
|
||||
status String @default("active") @db.VarChar(32)
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
owner Employee @relation("ContractRegistryOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
document_refs ConfidentialDocumentRef[]
|
||||
|
||||
@@index([project_id, status], map: "idx_contract_registry_project_status")
|
||||
@@index([owner_employee_id, created_at], map: "idx_contract_registry_owner_created")
|
||||
@@map("contract_registry")
|
||||
}
|
||||
|
||||
model ProjectDeliveryRecord {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String @db.Uuid
|
||||
title String
|
||||
record_type ProjectDeliveryRecordType
|
||||
summary String
|
||||
occurred_at DateTime
|
||||
owner_employee_id String @db.Uuid
|
||||
|
||||
created_at DateTime @default(now())
|
||||
|
||||
project Project @relation(fields: [project_id], references: [id], onDelete: Cascade)
|
||||
owner Employee @relation("ProjectDeliveryRecordOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([project_id, occurred_at], map: "idx_project_delivery_records_project_occurred")
|
||||
@@map("project_delivery_records")
|
||||
}
|
||||
|
||||
model ConfidentialDocumentRef {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
project_id String? @db.Uuid
|
||||
contract_registry_id String? @db.Uuid
|
||||
title String
|
||||
document_no String?
|
||||
confidentiality_level ConfidentialityLevel @default(internal)
|
||||
storage_type DocumentStorageType
|
||||
storage_path String
|
||||
offline_archive_no String?
|
||||
owner_employee_id String @db.Uuid
|
||||
access_rule_json Json?
|
||||
|
||||
created_at DateTime @default(now())
|
||||
updated_at DateTime @default(now()) @updatedAt
|
||||
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
contract_registry ContractRegistry? @relation(fields: [contract_registry_id], references: [id], onDelete: SetNull)
|
||||
owner Employee @relation("ConfidentialDocumentRefOwner", fields: [owner_employee_id], references: [id], onDelete: Restrict)
|
||||
|
||||
@@index([project_id, confidentiality_level], map: "idx_confidential_doc_refs_project_level")
|
||||
@@index([contract_registry_id], map: "idx_confidential_doc_refs_contract")
|
||||
@@index([owner_employee_id, created_at], map: "idx_confidential_doc_refs_owner_created")
|
||||
@@map("confidential_document_refs")
|
||||
}
|
||||
|
||||
model MigrationRun {
|
||||
id String @id @default(uuid()) @db.Uuid
|
||||
run_type String @db.VarChar(32)
|
||||
@@ -1744,6 +2054,7 @@ model TravelRequest {
|
||||
legal_entity_id String @db.Uuid
|
||||
cost_center_id String @db.Uuid
|
||||
budget_pool_id String? @db.Uuid
|
||||
project_id String? @db.Uuid
|
||||
purpose String
|
||||
trip_type TravelTripType
|
||||
start_date DateTime @db.Date
|
||||
@@ -1762,6 +2073,7 @@ model TravelRequest {
|
||||
legal_entity LegalEntity @relation(fields: [legal_entity_id], references: [id], onDelete: Restrict)
|
||||
cost_center CostCenter @relation(fields: [cost_center_id], references: [id], onDelete: Restrict)
|
||||
budget_pool BudgetPool? @relation(fields: [budget_pool_id], references: [id], onDelete: SetNull)
|
||||
project Project? @relation(fields: [project_id], references: [id], onDelete: SetNull)
|
||||
|
||||
segments TravelRequestSegment[]
|
||||
travel_orders TravelOrder[]
|
||||
@@ -1772,6 +2084,7 @@ model TravelRequest {
|
||||
|
||||
@@index([applicant_employee_id, start_date], map: "idx_travel_requests_applicant_start")
|
||||
@@index([submitter_id], map: "idx_travel_requests_submitter")
|
||||
@@index([project_id], map: "idx_travel_requests_project")
|
||||
@@index([department_id, status], map: "idx_travel_requests_department_status")
|
||||
@@index([legal_entity_id, cost_center_id], map: "idx_travel_requests_legal_cost_center")
|
||||
@@index([budget_pool_id], map: "idx_travel_requests_budget_pool")
|
||||
|
||||
@@ -16,6 +16,7 @@ const authPlugin = require("./plugins/auth");
|
||||
const prismaPlugin = require("./plugins/prisma");
|
||||
const swaggerDocsPlugin = require("./plugins/swagger");
|
||||
const authRoutes = require("./routes/auth");
|
||||
const wecomAuthRoutes = require("./routes/wecomAuth");
|
||||
const meRoutes = require("./routes/me");
|
||||
const reimbursementRoutes = require("./routes/reimbursements");
|
||||
const invoicePoolRoutes = require("./routes/invoicePool");
|
||||
@@ -52,6 +53,9 @@ const systemWorkspaceRoutes = require("./routes/systemWorkspace");
|
||||
const presenceRoutes = require("./routes/presence");
|
||||
const weeklyReportsRoutes = require("./routes/weeklyReports");
|
||||
const todosRoutes = require("./routes/todos");
|
||||
const projectsRoutes = require("./routes/projects");
|
||||
const contractsRegistryRoutes = require("./routes/contractsRegistry");
|
||||
const confidentialDocRefsRoutes = require("./routes/confidentialDocRefs");
|
||||
const purchaseRequestRoutes = require("./routes/purchaseRequests");
|
||||
const assetsRoutes = require("./routes/assets");
|
||||
const assetMovementsRoutes = require("./routes/assetMovements");
|
||||
@@ -297,6 +301,7 @@ async function buildApp() {
|
||||
}
|
||||
|
||||
await app.register(authRoutes, { prefix: "/api/auth" });
|
||||
await app.register(wecomAuthRoutes, { prefix: "/api/auth" });
|
||||
await app.register(meRoutes, { prefix: "/api" });
|
||||
await app.register(reimbursementRoutes, { prefix: "/api" });
|
||||
await app.register(invoicePoolRoutes, { prefix: "/api" });
|
||||
@@ -332,6 +337,9 @@ async function buildApp() {
|
||||
await app.register(presenceRoutes, { prefix: "/api" });
|
||||
await app.register(weeklyReportsRoutes, { prefix: "/api" });
|
||||
await app.register(todosRoutes, { prefix: "/api" });
|
||||
await app.register(projectsRoutes, { prefix: "/api" });
|
||||
await app.register(contractsRegistryRoutes, { prefix: "/api" });
|
||||
await app.register(confidentialDocRefsRoutes, { prefix: "/api" });
|
||||
await app.register(purchaseRequestRoutes, { prefix: "/api" });
|
||||
await app.register(assetsRoutes, { prefix: "/api" });
|
||||
await app.register(assetMovementsRoutes, { prefix: "/api" });
|
||||
|
||||
@@ -56,6 +56,14 @@ function getConfig() {
|
||||
oidcRedirectUri: process.env.OIDC_REDIRECT_URI ?? "",
|
||||
oidcScopes: process.env.OIDC_SCOPES ?? "openid profile email",
|
||||
|
||||
wecomEnabled: process.env.WECOM_ENABLED === "true",
|
||||
wecomCorpId: process.env.WECOM_CORP_ID ?? "",
|
||||
wecomAgentId: process.env.WECOM_AGENT_ID ?? "",
|
||||
wecomAgentSecret: process.env.WECOM_AGENT_SECRET ?? "",
|
||||
wecomOauthEnabled: process.env.WECOM_OAUTH_ENABLED === "true",
|
||||
wecomRedirectUri: process.env.WECOM_REDIRECT_URI ?? "",
|
||||
wecomAllowedReturnHosts: process.env.WECOM_ALLOWED_RETURN_HOSTS ?? "",
|
||||
|
||||
appTimezone: process.env.APP_TIMEZONE ?? "Asia/Shanghai",
|
||||
storageDir: path.resolve(serverRoot, storageDir),
|
||||
storageDriver: process.env.STORAGE_DRIVER ?? "local",
|
||||
@@ -115,6 +123,11 @@ function getConfig() {
|
||||
weeklyReportEnabled: process.env.WEEKLY_REPORT_ENABLED === "true",
|
||||
employeeLifecycleEnabled: process.env.EMPLOYEE_LIFECYCLE_ENABLED === "true",
|
||||
todoBoardEnabled: process.env.TODO_BOARD_ENABLED === "true",
|
||||
wecomEntryEnabled: process.env.WECOM_ENTRY_ENABLED === "true",
|
||||
projectDomainEnabled: process.env.PROJECT_DOMAIN_ENABLED === "true",
|
||||
projectLinkingEnabled: process.env.PROJECT_LINKING_ENABLED === "true",
|
||||
confidentialDocRefsEnabled: process.env.CONFIDENTIAL_DOC_REFS_ENABLED === "true",
|
||||
publicSecurityHardeningEnabled: process.env.PUBLIC_SECURITY_HARDENING_ENABLED === "true",
|
||||
|
||||
aiAssistEnabled: process.env.AI_ASSIST_ENABLED === "true",
|
||||
aiProvider: process.env.AI_PROVIDER ?? "mock",
|
||||
|
||||
@@ -29,6 +29,7 @@ const TRAVEL_SELF_SERVICE_PERMISSIONS = [
|
||||
];
|
||||
|
||||
const ASSET_SELF_SERVICE_PERMISSIONS = ["asset.view_own"];
|
||||
const PROJECT_SELF_SERVICE_PERMISSIONS = ["project.view_own"];
|
||||
|
||||
const MASTERDATA_VIEW_PERMISSIONS = ["masterdata.view"];
|
||||
const MASTERDATA_MANAGE_PERMISSIONS = ["masterdata.manage"];
|
||||
@@ -37,6 +38,7 @@ const EMPLOYEE_BASE_PERMISSIONS = [
|
||||
...EXPENSE_SELF_SERVICE_PERMISSIONS,
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
...TRAVEL_SELF_SERVICE_PERMISSIONS,
|
||||
...PROJECT_SELF_SERVICE_PERMISSIONS,
|
||||
];
|
||||
|
||||
const ENTITY_PERMISSION_MAP = {
|
||||
@@ -102,6 +104,11 @@ const ENTITY_PERMISSION_MAP = {
|
||||
approve: "travel.approve",
|
||||
manage: "travel.manage",
|
||||
},
|
||||
project: {
|
||||
view_own: "project.view_own",
|
||||
view_all: "project.view_all",
|
||||
manage: "project.manage",
|
||||
},
|
||||
};
|
||||
|
||||
const ROLE_PERMISSIONS = {
|
||||
@@ -123,8 +130,13 @@ const ROLE_PERMISSIONS = {
|
||||
"travel.view_all",
|
||||
"asset.view_all",
|
||||
"report.dashboard.view",
|
||||
"project.view_all",
|
||||
"budget.view",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
"budget.manage",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
],
|
||||
ceo: [
|
||||
...EMPLOYEE_BASE_PERMISSIONS,
|
||||
@@ -187,6 +199,7 @@ const ROLE_PERMISSIONS = {
|
||||
"system.audit.view",
|
||||
"payment.batch.manage",
|
||||
"risk.review.manage",
|
||||
"project.view_all",
|
||||
],
|
||||
administration: [
|
||||
"expense.proxy_submit",
|
||||
@@ -199,6 +212,8 @@ const ROLE_PERMISSIONS = {
|
||||
"asset.view_all",
|
||||
"asset.manage",
|
||||
"asset.transfer",
|
||||
"project.view_all",
|
||||
"project.manage",
|
||||
],
|
||||
hr: [
|
||||
...LEAVE_SELF_SERVICE_PERMISSIONS,
|
||||
|
||||
+82
-4
@@ -29,6 +29,85 @@ function getRequestOrigin(request) {
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeOriginValue(value) {
|
||||
if (typeof value !== "string") {
|
||||
return null;
|
||||
}
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new URL(trimmed).origin.replace(/\/+$/, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function parseAllowedOriginContext(config, request) {
|
||||
const allowedOrigins = new Set();
|
||||
const allowedHosts = new Set();
|
||||
|
||||
for (const item of String(config.appOrigin || "").split(",").map((value) => value.trim()).filter(Boolean)) {
|
||||
const normalized = normalizeOriginValue(item);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
allowedOrigins.add(normalized);
|
||||
try {
|
||||
allowedHosts.add(new URL(normalized).host);
|
||||
} catch {
|
||||
// ignore invalid app origin host
|
||||
}
|
||||
}
|
||||
|
||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((value) => value.trim()).filter(Boolean)) {
|
||||
allowedHosts.add(item);
|
||||
}
|
||||
|
||||
if (!allowedOrigins.size) {
|
||||
const fallbackOrigin = normalizeOriginValue(resolvePrimaryAppOrigin(config, request));
|
||||
if (fallbackOrigin) {
|
||||
allowedOrigins.add(fallbackOrigin);
|
||||
try {
|
||||
allowedHosts.add(new URL(fallbackOrigin).host);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { allowedOrigins, allowedHosts };
|
||||
}
|
||||
|
||||
function isRequestOriginAllowed(config, request) {
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
if (!requestOrigin) {
|
||||
return { allowed: true, requestOrigin: null, expectedOrigins: [] };
|
||||
}
|
||||
|
||||
const normalizedRequestOrigin = normalizeOriginValue(requestOrigin);
|
||||
if (!normalizedRequestOrigin) {
|
||||
return { allowed: false, requestOrigin, expectedOrigins: [] };
|
||||
}
|
||||
|
||||
const { allowedOrigins, allowedHosts } = parseAllowedOriginContext(config, request);
|
||||
if (allowedOrigins.has(normalizedRequestOrigin)) {
|
||||
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
|
||||
try {
|
||||
const host = new URL(normalizedRequestOrigin).host;
|
||||
if (allowedHosts.has(host)) {
|
||||
return { allowed: true, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
} catch {
|
||||
// ignore invalid host
|
||||
}
|
||||
|
||||
return { allowed: false, requestOrigin: normalizedRequestOrigin, expectedOrigins: Array.from(allowedOrigins) };
|
||||
}
|
||||
|
||||
function refreshCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
@@ -361,16 +440,15 @@ async function authRoutes(fastify) {
|
||||
});
|
||||
}
|
||||
|
||||
const expectedOrigin = fastify.config.appOrigin;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
if (expectedOrigin && requestOrigin && requestOrigin !== expectedOrigin) {
|
||||
const originCheck = isRequestOriginAllowed(fastify.config, request);
|
||||
if (!originCheck.allowed) {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: session.employee_id,
|
||||
action: "AUTH_REFRESH_ORIGIN_MISMATCH",
|
||||
targetType: "employee",
|
||||
targetId: session.employee_id,
|
||||
extra: { expectedOrigin, requestOrigin },
|
||||
extra: { expectedOrigins: originCheck.expectedOrigins, requestOrigin: originCheck.requestOrigin },
|
||||
}),
|
||||
});
|
||||
throw new AppError({
|
||||
|
||||
@@ -19,6 +19,7 @@ async function meRoutes(fastify) {
|
||||
direct_manager_id: true,
|
||||
status: true,
|
||||
roles: { select: { role_code: true } },
|
||||
wecom_identity_binding: { select: { external_user_id: true, created_at: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -33,6 +34,7 @@ async function meRoutes(fastify) {
|
||||
directManagerId: employee?.direct_manager_id ?? null,
|
||||
status: employee?.status ?? "active",
|
||||
roleCodes,
|
||||
wecomBinding: employee?.wecom_identity_binding ? { externalUserId: employee.wecom_identity_binding.external_user_id, createdAt: employee.wecom_identity_binding.created_at?.toISOString?.() ?? null } : null,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,402 @@
|
||||
const crypto = require("node:crypto");
|
||||
const { z } = require("zod");
|
||||
|
||||
const { AppError } = require("../utils/AppError");
|
||||
const { buildAuditLogData } = require("../utils/audit");
|
||||
const { randomRefreshToken, sha256Hex } = require("../utils/crypto");
|
||||
const { signPayload, verifyToken } = require("../utils/signedUrl");
|
||||
const { sendData } = require("../utils/reply");
|
||||
|
||||
const BIND_COOKIE_NAME = "wecom_bind";
|
||||
const DATE_WINDOW_MS = 10 * 60 * 1000;
|
||||
const QUERY_SCHEMA = z.object({
|
||||
returnTo: z.string().optional(),
|
||||
mode: z.enum(["login", "bind"]).optional(),
|
||||
});
|
||||
const BIND_BODY_SCHEMA = z.object({ returnTo: z.string().optional() }).strict().optional();
|
||||
|
||||
function refreshCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "strict",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth",
|
||||
maxAge: config.refreshTtlSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
function bindCookieOptions(config) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: config.cookieSecure,
|
||||
path: "/api/auth/wecom",
|
||||
maxAge: DATE_WINDOW_MS / 1000,
|
||||
};
|
||||
}
|
||||
|
||||
function resolvePrimaryAppOrigin(config, request) {
|
||||
const configured = typeof config.appOrigin === "string" ? config.appOrigin : "";
|
||||
const first = configured
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)[0];
|
||||
if (first) return first;
|
||||
|
||||
const host = request.headers.host ?? "127.0.0.1:3000";
|
||||
const protocol = request.protocol ?? "http";
|
||||
return `${protocol}://${host}`;
|
||||
}
|
||||
|
||||
function parseAllowedReturnHosts(config, request) {
|
||||
const hosts = new Set();
|
||||
const appOrigins = String(config.appOrigin || "")
|
||||
.split(",")
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
for (const item of appOrigins) {
|
||||
try {
|
||||
hosts.add(new URL(item).host);
|
||||
} catch {
|
||||
// ignore invalid origin
|
||||
}
|
||||
}
|
||||
for (const item of String(config.wecomAllowedReturnHosts || "").split(",").map((v) => v.trim()).filter(Boolean)) {
|
||||
hosts.add(item);
|
||||
}
|
||||
if (!hosts.size) {
|
||||
try {
|
||||
hosts.add(new URL(resolvePrimaryAppOrigin(config, request)).host);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return hosts;
|
||||
}
|
||||
|
||||
function sanitizeReturnTo(rawValue, config, request) {
|
||||
if (typeof rawValue !== "string") {
|
||||
return "/portal";
|
||||
}
|
||||
const trimmed = rawValue.trim();
|
||||
if (!trimmed) {
|
||||
return "/portal";
|
||||
}
|
||||
if (trimmed.startsWith("/")) {
|
||||
return trimmed.startsWith("//") ? "/portal" : trimmed;
|
||||
}
|
||||
try {
|
||||
const url = new URL(trimmed);
|
||||
const allowedHosts = parseAllowedReturnHosts(config, request);
|
||||
if (!allowedHosts.has(url.host)) {
|
||||
return "/portal";
|
||||
}
|
||||
return `${url.pathname || "/portal"}${url.search || ""}${url.hash || ""}`;
|
||||
} catch {
|
||||
return "/portal";
|
||||
}
|
||||
}
|
||||
|
||||
function assertWecomEnabled(config) {
|
||||
if (!config.wecomEnabled || !config.wecomOauthEnabled) {
|
||||
throw new AppError({ code: "SYS_404", message: "Not Found", statusCode: 404 });
|
||||
}
|
||||
if (!config.wecomCorpId || !config.wecomAgentId || !config.wecomRedirectUri) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { wecom: "missing_required_config" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function getWecomAccessToken(config) {
|
||||
if (!config.wecomAgentSecret) {
|
||||
throw new AppError({
|
||||
code: "VALID_001",
|
||||
message: "参数校验失败",
|
||||
statusCode: 409,
|
||||
details: { wecom: "missing_agent_secret" },
|
||||
});
|
||||
}
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/gettoken");
|
||||
url.searchParams.set("corpid", config.wecomCorpId);
|
||||
url.searchParams.set("corpsecret", config.wecomAgentSecret);
|
||||
const response = await fetch(url);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0 || !payload.access_token) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "token_exchange_failed", payload },
|
||||
});
|
||||
}
|
||||
return String(payload.access_token);
|
||||
}
|
||||
|
||||
async function exchangeWecomCode(config, code) {
|
||||
const accessToken = await getWecomAccessToken(config);
|
||||
const url = new URL("https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo");
|
||||
url.searchParams.set("access_token", accessToken);
|
||||
url.searchParams.set("code", code);
|
||||
const response = await fetch(url);
|
||||
const payload = await response.json().catch(() => null);
|
||||
if (!response.ok || !payload || Number(payload.errcode ?? 0) !== 0) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "userinfo_exchange_failed", payload },
|
||||
});
|
||||
}
|
||||
return {
|
||||
externalUserId: typeof payload.UserId === "string" && payload.UserId.trim() ? payload.UserId.trim() : null,
|
||||
externalOpenUserId: typeof payload.OpenId === "string" && payload.OpenId.trim() ? payload.OpenId.trim() : null,
|
||||
deviceId: typeof payload.DeviceId === "string" ? payload.DeviceId : null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildWecomAuthorizeUrl(config, state) {
|
||||
const redirectUri = encodeURIComponent(config.wecomRedirectUri);
|
||||
const scope = "snsapi_base";
|
||||
return `https://open.weixin.qq.com/connect/oauth2/authorize?appid=${encodeURIComponent(config.wecomCorpId)}&redirect_uri=${redirectUri}&response_type=code&scope=${scope}&agentid=${encodeURIComponent(config.wecomAgentId)}&state=${encodeURIComponent(state)}#wechat_redirect`;
|
||||
}
|
||||
|
||||
async function createAuthenticatedSession({ prisma, request, config, employeeId, tokenVersion, deviceFingerprint, auditAction, extra = {} }) {
|
||||
const refreshToken = randomRefreshToken();
|
||||
const refreshTokenHash = sha256Hex(refreshToken);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + config.refreshTtlSeconds * 1000);
|
||||
|
||||
await prisma.$transaction(async (tx) => {
|
||||
const activeSessions = await tx.authSession.findMany({
|
||||
where: {
|
||||
employee_id: employeeId,
|
||||
revoked_at: null,
|
||||
expires_at: { gt: now },
|
||||
},
|
||||
orderBy: { created_at: "asc" },
|
||||
select: { id: true },
|
||||
});
|
||||
const maxSessions = 5;
|
||||
const allowedExisting = maxSessions - 1;
|
||||
if (activeSessions.length > allowedExisting) {
|
||||
const revokeCount = activeSessions.length - allowedExisting;
|
||||
const idsToRevoke = activeSessions.slice(0, revokeCount).map((s) => s.id);
|
||||
await tx.authSession.updateMany({ where: { id: { in: idsToRevoke } }, data: { revoked_at: now } });
|
||||
}
|
||||
|
||||
await tx.authSession.create({
|
||||
data: {
|
||||
employee_id: employeeId,
|
||||
refresh_token_hash: refreshTokenHash,
|
||||
device_fingerprint: deviceFingerprint,
|
||||
ip_address: request.ip,
|
||||
user_agent: String(request.headers["user-agent"] ?? ""),
|
||||
expires_at: expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employeeId,
|
||||
action: auditAction,
|
||||
targetType: "employee",
|
||||
targetId: employeeId,
|
||||
extra,
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
refreshToken,
|
||||
accessToken: request.server.issueAccessToken({ id: employeeId, token_version: tokenVersion }),
|
||||
};
|
||||
}
|
||||
|
||||
async function wecomAuthRoutes(fastify) {
|
||||
fastify.get("/wecom/config", async (request, reply) => {
|
||||
const enabled = Boolean(fastify.config.wecomEnabled && fastify.config.wecomOauthEnabled && fastify.config.wecomCorpId && fastify.config.wecomAgentId && fastify.config.wecomRedirectUri);
|
||||
return sendData(reply, request, {
|
||||
enabled,
|
||||
corpId: enabled ? fastify.config.wecomCorpId : "",
|
||||
agentId: enabled ? fastify.config.wecomAgentId : "",
|
||||
oauthEnabled: enabled,
|
||||
loginUrl: enabled ? "/api/auth/wecom/login?returnTo=%2Fportal" : null,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.get("/wecom/login", async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const parsed = QUERY_SCHEMA.safeParse(request.query ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const mode = parsed.data.mode ?? "login";
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindState = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
if (!bindState.ok) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data.returnTo, fastify.config, request);
|
||||
const state = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
nonce: crypto.randomUUID(),
|
||||
returnTo,
|
||||
mode,
|
||||
}, fastify.config.jwtSecret);
|
||||
return reply.redirect(buildWecomAuthorizeUrl(fastify.config, state));
|
||||
});
|
||||
|
||||
fastify.post("/wecom/bind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const parsed = BIND_BODY_SCHEMA.safeParse(request.body ?? {});
|
||||
if (!parsed.success) {
|
||||
throw new AppError({ code: "VALID_001", message: "参数校验失败", statusCode: 400, details: parsed.error.flatten() });
|
||||
}
|
||||
const returnTo = sanitizeReturnTo(parsed.data?.returnTo, fastify.config, request);
|
||||
const token = signPayload({
|
||||
exp: Date.now() + DATE_WINDOW_MS,
|
||||
employeeId: request.employee.id,
|
||||
returnTo,
|
||||
}, fastify.config.jwtSecret);
|
||||
reply.setCookie(BIND_COOKIE_NAME, token, bindCookieOptions(fastify.config));
|
||||
return sendData(reply, request, {
|
||||
loginUrl: `/api/auth/wecom/login?mode=bind&returnTo=${encodeURIComponent(returnTo)}`,
|
||||
});
|
||||
});
|
||||
|
||||
fastify.post("/wecom/unbind", { preHandler: fastify.authenticate }, async (request, reply) => {
|
||||
await fastify.prisma.wecomIdentityBinding.deleteMany({ where: { employee_id: request.employee.id } });
|
||||
try {
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: request.employee.id,
|
||||
action: "AUTH_WECOM_UNBIND",
|
||||
targetType: "employee",
|
||||
targetId: request.employee.id,
|
||||
extra: {},
|
||||
}),
|
||||
});
|
||||
} catch (error) {
|
||||
request.log.error({ err: error }, "wecom unbind audit failed");
|
||||
}
|
||||
return sendData(reply, request, { ok: true });
|
||||
});
|
||||
|
||||
fastify.get("/wecom/callback", async (request, reply) => {
|
||||
assertWecomEnabled(fastify.config);
|
||||
const stateToken = String(request.query?.state ?? "").trim();
|
||||
const code = String(request.query?.code ?? "").trim();
|
||||
const verified = verifyToken(stateToken, fastify.config.jwtSecret);
|
||||
if (!verified.ok || !code) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const ctx = verified.payload ?? {};
|
||||
if (typeof ctx.exp !== "number" || ctx.exp <= Date.now()) {
|
||||
throw new AppError({ code: "AUTH_004", message: "Access Token 已过期/无效", statusCode: 401 });
|
||||
}
|
||||
const mode = ctx.mode === "bind" ? "bind" : "login";
|
||||
const returnTo = sanitizeReturnTo(ctx.returnTo, fastify.config, request);
|
||||
const profile = await exchangeWecomCode(fastify.config, code);
|
||||
if (!profile.externalUserId) {
|
||||
throw new AppError({
|
||||
code: "AUTH_003",
|
||||
message: "企业微信认证失败",
|
||||
statusCode: 401,
|
||||
details: { wecom: "missing_user_id" },
|
||||
});
|
||||
}
|
||||
|
||||
const origin = resolvePrimaryAppOrigin(fastify.config, request);
|
||||
|
||||
if (mode === "bind") {
|
||||
const bindCookie = request.cookies?.[BIND_COOKIE_NAME];
|
||||
const bindVerified = verifyToken(bindCookie, fastify.config.jwtSecret);
|
||||
if (!bindVerified.ok) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=expired`);
|
||||
}
|
||||
const bindPayload = bindVerified.payload ?? {};
|
||||
if (typeof bindPayload.employeeId !== "string") {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=invalid`);
|
||||
}
|
||||
|
||||
const employee = await fastify.prisma.employee.findUnique({
|
||||
where: { id: bindPayload.employeeId },
|
||||
select: { id: true, status: true },
|
||||
});
|
||||
if (!employee || employee.status !== "active") {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=employee_inactive`);
|
||||
}
|
||||
|
||||
const conflicting = await fastify.prisma.wecomIdentityBinding.findFirst({
|
||||
where: { external_user_id: profile.externalUserId, NOT: { employee_id: employee.id } },
|
||||
select: { employee_id: true },
|
||||
});
|
||||
if (conflicting) {
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=conflict`);
|
||||
}
|
||||
|
||||
await fastify.prisma.wecomIdentityBinding.upsert({
|
||||
where: { employee_id: employee.id },
|
||||
update: {
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: profile.externalOpenUserId,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
create: {
|
||||
employee_id: employee.id,
|
||||
external_user_id: profile.externalUserId,
|
||||
external_open_user_id: profile.externalOpenUserId,
|
||||
created_by: employee.id,
|
||||
updated_by: employee.id,
|
||||
},
|
||||
});
|
||||
await fastify.prisma.auditLog.create({
|
||||
data: buildAuditLogData(request, {
|
||||
actorId: employee.id,
|
||||
action: "AUTH_WECOM_BIND",
|
||||
targetType: "employee",
|
||||
targetId: employee.id,
|
||||
extra: { externalUserId: profile.externalUserId },
|
||||
}),
|
||||
});
|
||||
reply.clearCookie(BIND_COOKIE_NAME, bindCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}?wecomBind=success`);
|
||||
}
|
||||
|
||||
const binding = await fastify.prisma.wecomIdentityBinding.findUnique({
|
||||
where: { external_user_id: profile.externalUserId },
|
||||
select: { employee_id: true, employee: { select: { id: true, status: true, token_version: true } } },
|
||||
});
|
||||
if (!binding?.employee || binding.employee.status !== "active") {
|
||||
return reply.redirect(`${origin}/login?redirect=${encodeURIComponent(returnTo)}&wecom=unbound`);
|
||||
}
|
||||
|
||||
const deviceFingerprint = `wecom:${profile.externalUserId}`;
|
||||
const session = await createAuthenticatedSession({
|
||||
prisma: fastify.prisma,
|
||||
request,
|
||||
config: fastify.config,
|
||||
employeeId: binding.employee.id,
|
||||
tokenVersion: binding.employee.token_version,
|
||||
deviceFingerprint,
|
||||
auditAction: "AUTH_WECOM_LOGIN_SUCCESS",
|
||||
extra: { externalUserId: profile.externalUserId },
|
||||
});
|
||||
|
||||
reply.setCookie(fastify.config.refreshCookieName, session.refreshToken, refreshCookieOptions(fastify.config));
|
||||
return reply.redirect(`${origin}${returnTo}`);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = wecomAuthRoutes;
|
||||
@@ -310,6 +310,41 @@ const DEFAULT_FEATURE_FLAGS = [
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "wecom_entry_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "project_domain_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "project_linking_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "confidential_doc_refs_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
{
|
||||
flag_key: "public_security_hardening_v1",
|
||||
override_mode: "force_off",
|
||||
rollout_type: "global",
|
||||
department_ids: [],
|
||||
note: "bootstrap default: force_off",
|
||||
},
|
||||
];
|
||||
|
||||
const DEFAULT_BUDGET_CONTROL_RULES = [
|
||||
|
||||
@@ -14,6 +14,11 @@ const FEATURE_FLAG_KEYS = {
|
||||
weeklyReport: "weekly_report_v1",
|
||||
employeeLifecycle: "employee_lifecycle_v1",
|
||||
todoBoard: "todo_board_v1",
|
||||
wecomEntry: "wecom_entry_v1",
|
||||
projectDomain: "project_domain_v1",
|
||||
projectLinking: "project_linking_v1",
|
||||
confidentialDocRefs: "confidential_doc_refs_v1",
|
||||
publicSecurityHardening: "public_security_hardening_v1",
|
||||
};
|
||||
|
||||
const FEATURE_FLAG_KEY_LIST = Object.freeze(Object.values(FEATURE_FLAG_KEYS));
|
||||
@@ -127,6 +132,26 @@ function resolveEnvGate(config, flagKey) {
|
||||
return Boolean(config.todoBoardEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.wecomEntry) {
|
||||
return Boolean(config.wecomEntryEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.projectDomain) {
|
||||
return Boolean(config.projectDomainEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.projectLinking) {
|
||||
return Boolean(config.projectLinkingEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.confidentialDocRefs) {
|
||||
return Boolean(config.confidentialDocRefsEnabled);
|
||||
}
|
||||
|
||||
if (flagKey === FEATURE_FLAG_KEYS.publicSecurityHardening) {
|
||||
return Boolean(config.publicSecurityHardeningEnabled);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
在新工单中引用
屏蔽一个用户