From 215e3967bc60e3fe5394b6e010d363d15eb28e86 Mon Sep 17 00:00:00 2001 From: shunli Date: Sun, 22 Mar 2026 08:33:53 +0800 Subject: [PATCH] =?UTF-8?q?R18-1/R18-3:=20=E6=8E=A5=E5=85=A5=E4=BC=81?= =?UTF-8?q?=E4=B8=9A=E5=BE=AE=E4=BF=A1=E5=85=A5=E5=8F=A3=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E4=B8=8E=E5=85=8D=E7=99=BB=E5=9B=9E=E9=80=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/src/api/auth.ts | 26 ++ client/src/pages/LoginPage.tsx | 45 +- client/src/pages/ProfilePage.tsx | 29 ++ client/src/router/index.tsx | 27 ++ deploy/trialrun/.env.example | 17 + .../migration.sql | 357 ++++++++++++++++ server/prisma/schema.prisma | 333 ++++++++++++++- server/src/app.js | 8 + server/src/config.js | 13 + server/src/constants/permissions.js | 15 + server/src/routes/auth.js | 86 +++- server/src/routes/me.js | 2 + server/src/routes/wecomAuth.js | 402 ++++++++++++++++++ server/src/scripts/seed-config.js | 35 ++ server/src/services/featureFlags/index.js | 25 ++ 15 files changed, 1394 insertions(+), 26 deletions(-) create mode 100644 server/prisma/migrations/20260321233000_r18_r21_unified_workbench_core/migration.sql create mode 100644 server/src/routes/wecomAuth.js diff --git a/client/src/api/auth.ts b/client/src/api/auth.ts index 45fe5e62..93036816 100644 --- a/client/src/api/auth.ts +++ b/client/src/api/auth.ts @@ -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("/api/auth/sso/providers", { auth: false }); } + +export async function fetchWecomConfig() { + return apiRequestData("/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({}), + }); +} diff --git a/client/src/pages/LoginPage.tsx b/client/src/pages/LoginPage.tsx index 205f359d..370b51b9 100644 --- a/client/src/pages/LoginPage.tsx +++ b/client/src/pages/LoginPage.tsx @@ -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) { event.preventDefault(); @@ -114,19 +120,34 @@ export function LoginPage() { - {oidcProvider ? ( + {oidcProvider || wecomEntry ? (

企业统一入口

- + {wecomEntry?.loginUrl ? ( + + ) : null} + {oidcProvider ? ( + + ) : null}
) : null} diff --git a/client/src/pages/ProfilePage.tsx b/client/src/pages/ProfilePage.tsx index e95fe3e8..5511fc7b 100644 --- a/client/src/pages/ProfilePage.tsx +++ b/client/src/pages/ProfilePage.tsx @@ -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 (
@@ -38,6 +52,21 @@ export function ProfilePage() { +
+

企业微信绑定

+
+
接入状态:{wecomConfigQuery.data?.enabled ? "已启用" : "未启用"}
+
绑定状态:{me?.wecomBinding?.externalUserId ?? "未绑定"}
+ {searchParams.get("wecomBind") ? ( +
绑定结果:{searchParams.get("wecomBind")}
+ ) : null} +
+ + +
+
+
+

权限列表

diff --git a/client/src/router/index.tsx b/client/src/router/index.tsx index 72d7847c..0a1ee374 100644 --- a/client/src/router/index.tsx +++ b/client/src/router/index.tsx @@ -35,6 +35,9 @@ import { PeoplePage, PortalAnnouncementsPage, PortalHomePage, + ProjectsPage, + ProjectDetailPage, + ContractsRegistryPage, SystemSettingsPage, TeamPresencePage, TodosPage, @@ -92,6 +95,30 @@ export function AppRouter() { )} /> )} /> )} /> + + {withRouteFallback()} + + } + /> + + {withRouteFallback()} + + } + /> + + {withRouteFallback()} + + } + /> )} /> )} /> } /> diff --git a/deploy/trialrun/.env.example b/deploy/trialrun/.env.example index f89515a5..a3255e1b 100644 --- a/deploy/trialrun/.env.example +++ b/deploy/trialrun/.env.example @@ -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" diff --git a/server/prisma/migrations/20260321233000_r18_r21_unified_workbench_core/migration.sql b/server/prisma/migrations/20260321233000_r18_r21_unified_workbench_core/migration.sql new file mode 100644 index 00000000..e14b55d8 --- /dev/null +++ b/server/prisma/migrations/20260321233000_r18_r21_unified_workbench_core/migration.sql @@ -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; + diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index d9424adb..3433b2d3 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -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") diff --git a/server/src/app.js b/server/src/app.js index 59c2f00d..6681f74e 100644 --- a/server/src/app.js +++ b/server/src/app.js @@ -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" }); diff --git a/server/src/config.js b/server/src/config.js index 83e0ec5a..b4c386f8 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -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", diff --git a/server/src/constants/permissions.js b/server/src/constants/permissions.js index e1a3c447..90ab4b48 100644 --- a/server/src/constants/permissions.js +++ b/server/src/constants/permissions.js @@ -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, diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index c0ecb49f..e0f8f887 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -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({ diff --git a/server/src/routes/me.js b/server/src/routes/me.js index b78f92b0..2d3f790a 100644 --- a/server/src/routes/me.js +++ b/server/src/routes/me.js @@ -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, }); }, ); diff --git a/server/src/routes/wecomAuth.js b/server/src/routes/wecomAuth.js new file mode 100644 index 00000000..8419c7e9 --- /dev/null +++ b/server/src/routes/wecomAuth.js @@ -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; diff --git a/server/src/scripts/seed-config.js b/server/src/scripts/seed-config.js index b98940f7..4cb99237 100644 --- a/server/src/scripts/seed-config.js +++ b/server/src/scripts/seed-config.js @@ -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 = [ diff --git a/server/src/services/featureFlags/index.js b/server/src/services/featureFlags/index.js index 6ef31c49..a965fcb5 100644 --- a/server/src/services/featureFlags/index.js +++ b/server/src/services/featureFlags/index.js @@ -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; }