提交 19d552d3 authored 作者: 王鹏飞's avatar 王鹏飞

chore: update

上级 dc447146
...@@ -9,16 +9,13 @@ MYSQL_USER=root ...@@ -9,16 +9,13 @@ MYSQL_USER=root
MYSQL_PASSWORD= MYSQL_PASSWORD=
MYSQL_DATABASE=com_dms MYSQL_DATABASE=com_dms
DINGTALK_APP_ID= # DMS unified authentication and authorization
DINGTALK_AGENT_ID= # Only set DMS_AUTH_DISABLED=true for local development without SSO.
DINGTALK_APP_KEY= DMS_AUTH_DISABLED=false
DINGTALK_APP_SECRET= SSO_USER_INFO_URL=https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info
DINGTALK_CORP_ID= USER_CENTER_BATCH_URL=https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info
DINGTALK_STREAM_DEBUG=false USER_CENTER_SALT=
PERMISSION_API_URL=
DINGTALK_PROJECT_APPROVAL_PROCESS_CODE= PERMISSION_APP_ID=
DINGTALK_CONTRACT_APPROVAL_PROCESS_CODE= PERMISSION_APP_SECRET=
DINGTALK_ACCEPTANCE_APPROVAL_PROCESS_CODE= PERMISSION_CACHE_TTL_SECONDS=180
WX_APPID_1=
WX_SECRET_1=
SERVER_PORT=5101
MONGODB_URI=mongodb://log:log%402025@dds-2ze70e3ecb9601941614-pub.mongodb.rds.aliyuncs.com:3717/logs_data
DATABASE_URL=
MYSQL_HOST=rm-2ze7v8pwfd71ve6m3to.mysql.rds.aliyuncs.com
MYSQL_PORT=12060
MYSQL_USER=u_com_dms
MYSQL_PASSWORD=com_dms2026
MYSQL_DATABASE=com_dms
# DMS unified authentication and authorization
# Only set DMS_AUTH_DISABLED=true for local development without SSO.
DMS_AUTH_DISABLED=false
SSO_USER_INFO_URL=https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info
USER_CENTER_BATCH_URL=https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info
USER_CENTER_SALT=n7xAav2tSjn9Ysnr
PERMISSION_API_URL=https://permissions-api.ezijing.com
PERMISSION_APP_ID=ezijing_82b452761ada1b79fd12b9fcbd73629e
PERMISSION_APP_SECRET=f19d1e788ac9ea0e3c72ef2e3caee8f0
PERMISSION_CACHE_TTL_SECONDS=180
...@@ -62,10 +62,10 @@ src/ ...@@ -62,10 +62,10 @@ src/
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| |--------|------|-------------|
| POST | `/logs` | Create single log | | POST | `/api/logs` | Create single log |
| POST | `/logs/batch` | Create batch logs | | POST | `/api/logs/batch` | Create batch logs |
| GET | `/logs` | Query logs | | GET | `/api/logs` | Query logs |
| GET | `/logs/stats` | Get log statistics | | GET | `/api/logs/stats` | Get log statistics |
#### Log Request Body #### Log Request Body
......
# 交付管理系统权限中心配置
## 后端环境变量
正式环境必须配置 `PERMISSION_API_URL`、`PERMISSION_APP_ID` 和 `PERMISSION_APP_SECRET`。AppSecret 只允许通过部署环境注入,不得提交到 Git。
成员列表还会由 DMS 服务端批量调用用户中心补充姓名、手机号和头像,需要配置 `USER_CENTER_BATCH_URL` 与用户中心签名盐 `USER_CENTER_SALT`。DMS 始终按业务要求返回完整手机号。
已在聊天或其他非密钥渠道出现过的 AppSecret 必须先在权限中心轮换。
## 角色
- `admin`:系统管理员
DMS 不再使用 `ordinary_user`、`project_manager`、`delivery_user` 或 `viewer` 作为权限中心系统角色。项目负责人、阶段负责人和查看人员都是 DMS 项目角色,只在具体项目中配置。
## 权限
- 项目管理:路由 `/dms/projects`
- 产品管理:路由 `/dms/products`
- 案例管理:路由 `/dms/cases`
权限名称和 `tag` 可以按权限中心规范填写,DMS 不读取 `tag`,只调用 `/api/v1/user/routes` 获取路由列表。用户拥有任意一个 `/dms/...` 路由即可进入 DMS,并只能访问匹配的路由前缀。`admin` 自动拥有全部模块权限。
## 推荐分配
| 用户类型 | 核心路由 |
| --- | --- |
| admin | 系统全部操作 |
| 项目用户 | `/dms/projects`;项目内具体操作仍受项目角色限制 |
| 产品用户 | `/dms/products` |
| 案例用户 | `/dms/cases` |
| 综合用户 | 按需配置上述多个模块路由 |
项目经理、采购负责人、合同负责人、交付负责人和验收负责人由交付系统的 `project_role_assignments` 管理,不在权限中心创建项目实例角色。
## 上线步骤
1. 轮换 AppSecret。
2. 在权限中心创建上述三个功能权限,并分别填写对应路由;系统角色只保留 `admin`。
3. 导入首批用户,以 `sso_id` 为用户标识。
4. 配置后端环境变量,保持 `DMS_AUTH_DISABLED=false`。
5. 建库后人工审核并执行 `migrations/001_init_dms_schema.sql`。本项目不执行 `drizzle-kit migrate`。
6. 重启 Node 服务。
7. 使用管理员、项目用户、产品用户、案例用户、综合用户和未授权用户分别完成验收。
-- DMS schema baseline (MySQL 8+)
-- Execute this SQL manually after reviewing the target database.
CREATE TABLE IF NOT EXISTS `product_list` ( CREATE TABLE IF NOT EXISTS `product_list` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键', `id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(120) NOT NULL COMMENT '产品名称', `name` varchar(120) NOT NULL COMMENT '产品名称',
...@@ -23,16 +26,12 @@ CREATE TABLE IF NOT EXISTS `project_list` ( ...@@ -23,16 +26,12 @@ CREATE TABLE IF NOT EXISTS `project_list` (
`department_name` varchar(255), `department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID', `product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255), `product_name` varchar(255),
`owner_user_id` varchar(64),
`owner_name` varchar(120),
`owner_mobile` varchar(64),
`owner_ding_user_id` varchar(128),
`contact_name` varchar(120), `contact_name` varchar(120),
`contact_title` varchar(120), `contact_title` varchar(120),
`contact_phone` varchar(64), `contact_phone` varchar(64),
`solution` text, `solution` text,
`stage` int NOT NULL DEFAULT 10, `stage` int NOT NULL DEFAULT 10,
`status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 10审批中 20已归档', `status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 20已归档',
`description` text, `description` text,
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID', `operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名', `operator_name` varchar(120) COMMENT '最后操作人姓名',
...@@ -42,8 +41,6 @@ CREATE TABLE IF NOT EXISTS `project_list` ( ...@@ -42,8 +41,6 @@ CREATE TABLE IF NOT EXISTS `project_list` (
UNIQUE KEY `uk_projects_project_code` (`project_code`), UNIQUE KEY `uk_projects_project_code` (`project_code`),
KEY `idx_projects_stage_status` (`stage`, `status`), KEY `idx_projects_stage_status` (`stage`, `status`),
KEY `idx_projects_product_id` (`product_id`), KEY `idx_projects_product_id` (`product_id`),
KEY `idx_projects_owner_user_id` (`owner_user_id`),
KEY `idx_projects_owner_mobile` (`owner_mobile`),
KEY `idx_projects_operator_user_id` (`operator_user_id`) KEY `idx_projects_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目主表'; ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目主表';
...@@ -66,8 +63,8 @@ CREATE TABLE IF NOT EXISTS `case_list` ( ...@@ -66,8 +63,8 @@ CREATE TABLE IF NOT EXISTS `case_list` (
CREATE TABLE IF NOT EXISTS `project_initiations` ( CREATE TABLE IF NOT EXISTS `project_initiations` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`application_file_url` varchar(500), `application_file_url` text COMMENT '附件JSON数组',
`argument_file_url` varchar(500), `argument_file_url` text COMMENT '附件JSON数组',
`project_amount` decimal(14,2), `project_amount` decimal(14,2),
`fund_source` varchar(255), `fund_source` varchar(255),
`execution_plan` text, `execution_plan` text,
...@@ -89,8 +86,8 @@ CREATE TABLE IF NOT EXISTS `project_procurements` ( ...@@ -89,8 +86,8 @@ CREATE TABLE IF NOT EXISTS `project_procurements` (
`main_bid_owner` varchar(120), `main_bid_owner` varchar(120),
`companion_bidders` text, `companion_bidders` text,
`formal_bid_status` varchar(120), `formal_bid_status` varchar(120),
`winning_notice_file_url` varchar(500), `winning_notice_file_url` text COMMENT '附件JSON数组',
`bid_archive_file_url` varchar(500), `bid_archive_file_url` text COMMENT '附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
...@@ -105,7 +102,7 @@ CREATE TABLE IF NOT EXISTS `project_contracts` ( ...@@ -105,7 +102,7 @@ CREATE TABLE IF NOT EXISTS `project_contracts` (
`contract_name` varchar(255), `contract_name` varchar(255),
`amount` decimal(14,2), `amount` decimal(14,2),
`drafter` varchar(120), `drafter` varchar(120),
`archive_file_url` varchar(500), `archive_file_url` text COMMENT '附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
...@@ -131,7 +128,7 @@ CREATE TABLE IF NOT EXISTS `project_deliveries` ( ...@@ -131,7 +128,7 @@ CREATE TABLE IF NOT EXISTS `project_deliveries` (
CREATE TABLE IF NOT EXISTS `project_acceptances` ( CREATE TABLE IF NOT EXISTS `project_acceptances` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500), `acceptance_report_url` text COMMENT '附件JSON数组',
`acceptance_note` text, `acceptance_note` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -148,6 +145,7 @@ CREATE TABLE IF NOT EXISTS `project_timelines` ( ...@@ -148,6 +145,7 @@ CREATE TABLE IF NOT EXISTS `project_timelines` (
`description` text, `description` text,
`stage` int NOT NULL, `stage` int NOT NULL,
`status` int NOT NULL, `status` int NOT NULL,
`operator_user_id` varchar(64) COMMENT '操作人系统用户ID',
`operator_name` varchar(120) COMMENT '操作人姓名', `operator_name` varchar(120) COMMENT '操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -158,38 +156,22 @@ CREATE TABLE IF NOT EXISTS `project_timelines` ( ...@@ -158,38 +156,22 @@ CREATE TABLE IF NOT EXISTS `project_timelines` (
CONSTRAINT `project_timelines_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list` (`id`) ON DELETE CASCADE CONSTRAINT `project_timelines_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list` (`id`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目时间线记录'; ) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目时间线记录';
CREATE TABLE IF NOT EXISTS `project_approval_instances` ( CREATE TABLE IF NOT EXISTS `project_role_assignments` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT, `id` bigint unsigned NOT NULL AUTO_INCREMENT,
`approval_key` enum('project_approval','contract_approval','acceptance_approval') NOT NULL,
`process_code` varchar(128) NOT NULL,
`process_instance_id` varchar(128),
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`project_code` varchar(64) NOT NULL, `sso_id` varchar(64) NOT NULL,
`originator_user_id` varchar(64), `user_name` varchar(120),
`originator_ding_user_id` varchar(128) NOT NULL,
`status` enum('created','running','approved','rejected','terminated','deleted','failed') NOT NULL DEFAULT 'created',
`form_snapshot` json,
`result_snapshot` json,
`error_message` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_approval_instances_process_instance_id` (`process_instance_id`),
KEY `idx_approval_instances_project_id` (`project_id`),
KEY `idx_approval_instances_project_code` (`project_code`),
KEY `idx_approval_instances_status` (`status`),
CONSTRAINT `project_approval_instances_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list` (`id`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目审批实例';
CREATE TABLE IF NOT EXISTS `dingtalk_user_bindings` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`system_user_id` varchar(64) NOT NULL,
`ding_user_id` varchar(128) NOT NULL,
`name` varchar(120),
`mobile` varchar(64), `mobile` varchar(64),
`role` varchar(32) NOT NULL,
`status` tinyint unsigned NOT NULL DEFAULT 1,
`assigned_by_sso_id` varchar(64),
`assigned_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`), PRIMARY KEY (`id`),
UNIQUE KEY `uk_dingtalk_user_bindings_system_user_id` (`system_user_id`), UNIQUE KEY `uk_project_roles_assignment` (`project_id`, `sso_id`, `role`),
UNIQUE KEY `uk_dingtalk_user_bindings_ding_user_id` (`ding_user_id`) KEY `idx_project_roles_project_id` (`project_id`),
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钉钉用户绑定关系'; KEY `idx_project_roles_sso_id` (`sso_id`),
KEY `idx_project_roles_project_role` (`project_id`, `role`),
CONSTRAINT `project_role_assignments_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list` (`id`) ON DELETE CASCADE
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目角色分工';
...@@ -21,16 +21,13 @@ CREATE TABLE `project_list` ( ...@@ -21,16 +21,13 @@ CREATE TABLE `project_list` (
`department_name` varchar(255), `department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID', `product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255), `product_name` varchar(255),
`owner_user_id` varchar(64),
`owner_name` varchar(120),
`owner_mobile` varchar(64),
`owner_ding_user_id` varchar(128),
`contact_name` varchar(120), `contact_name` varchar(120),
`contact_title` varchar(120), `contact_title` varchar(120),
`contact_phone` varchar(64), `contact_phone` varchar(64),
`solution` text, `solution` text,
`stage` int NOT NULL DEFAULT 10, `stage` int NOT NULL DEFAULT 10,
`status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 10审批中 20已归档', `status` int NOT NULL DEFAULT 0 COMMENT '项目状态: 0进行中 20已归档',
`description` text, `description` text,
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID', `operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名', `operator_name` varchar(120) COMMENT '最后操作人姓名',
...@@ -57,8 +54,8 @@ CREATE TABLE `case_list` ( ...@@ -57,8 +54,8 @@ CREATE TABLE `case_list` (
CREATE TABLE `project_initiations` ( CREATE TABLE `project_initiations` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL, `id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`application_file_url` varchar(500), `application_file_url` text,
`argument_file_url` varchar(500), `argument_file_url` text,
`project_amount` decimal(14,2), `project_amount` decimal(14,2),
`fund_source` varchar(255), `fund_source` varchar(255),
`execution_plan` text, `execution_plan` text,
...@@ -79,8 +76,8 @@ CREATE TABLE `project_procurements` ( ...@@ -79,8 +76,8 @@ CREATE TABLE `project_procurements` (
`main_bid_owner` varchar(120), `main_bid_owner` varchar(120),
`companion_bidders` text, `companion_bidders` text,
`formal_bid_status` varchar(120), `formal_bid_status` varchar(120),
`winning_notice_file_url` varchar(500), `winning_notice_file_url` text,
`bid_archive_file_url` varchar(500), `bid_archive_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_procurements_id` PRIMARY KEY(`id`), CONSTRAINT `project_procurements_id` PRIMARY KEY(`id`),
...@@ -94,7 +91,7 @@ CREATE TABLE `project_contracts` ( ...@@ -94,7 +91,7 @@ CREATE TABLE `project_contracts` (
`contract_name` varchar(255), `contract_name` varchar(255),
`amount` decimal(14,2), `amount` decimal(14,2),
`drafter` varchar(120), `drafter` varchar(120),
`archive_file_url` varchar(500), `archive_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_contracts_id` PRIMARY KEY(`id`), CONSTRAINT `project_contracts_id` PRIMARY KEY(`id`),
...@@ -118,7 +115,7 @@ CREATE TABLE `project_deliveries` ( ...@@ -118,7 +115,7 @@ CREATE TABLE `project_deliveries` (
CREATE TABLE `project_acceptances` ( CREATE TABLE `project_acceptances` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL, `id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500), `acceptance_report_url` text,
`acceptance_note` text, `acceptance_note` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
...@@ -134,43 +131,32 @@ CREATE TABLE `project_timelines` ( ...@@ -134,43 +131,32 @@ CREATE TABLE `project_timelines` (
`description` text, `description` text,
`stage` int NOT NULL, `stage` int NOT NULL,
`status` int NOT NULL, `status` int NOT NULL,
`operator_user_id` varchar(64) COMMENT '操作人系统用户ID',
`operator_name` varchar(120) COMMENT '操作人姓名', `operator_name` varchar(120) COMMENT '操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_timelines_id` PRIMARY KEY(`id`) CONSTRAINT `project_timelines_id` PRIMARY KEY(`id`)
) COMMENT='项目时间线记录'; ) COMMENT='项目时间线记录';
--> statement-breakpoint --> statement-breakpoint
CREATE TABLE `project_approval_instances` ( CREATE TABLE `project_role_assignments` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL, `id` bigint unsigned AUTO_INCREMENT NOT NULL,
`approval_key` enum('project_approval','contract_approval','acceptance_approval') NOT NULL,
`process_code` varchar(128) NOT NULL,
`process_instance_id` varchar(128),
`project_id` bigint unsigned NOT NULL, `project_id` bigint unsigned NOT NULL,
`project_code` varchar(64) NOT NULL, `sso_id` varchar(64) NOT NULL,
`originator_user_id` varchar(64), `user_name` varchar(120),
`originator_ding_user_id` varchar(128) NOT NULL,
`status` enum('created','running','approved','rejected','terminated','deleted','failed') NOT NULL DEFAULT 'created',
`form_snapshot` json,
`result_snapshot` json,
`error_message` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_approval_instances_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_approval_instances_process_instance_id` UNIQUE(`process_instance_id`)
) COMMENT='项目审批实例';
--> statement-breakpoint
CREATE TABLE `dingtalk_user_bindings` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`system_user_id` varchar(64) NOT NULL,
`ding_user_id` varchar(128) NOT NULL,
`name` varchar(120),
`mobile` varchar(64), `mobile` varchar(64),
`role` varchar(32) NOT NULL,
`status` tinyint unsigned NOT NULL DEFAULT 1,
`assigned_by_sso_id` varchar(64),
`assigned_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `dingtalk_user_bindings_id` PRIMARY KEY(`id`), CONSTRAINT `project_role_assignments_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_dingtalk_user_bindings_system_user_id` UNIQUE(`system_user_id`), CONSTRAINT `uk_project_roles_assignment` UNIQUE(`project_id`,`sso_id`,`role`)
CONSTRAINT `uk_dingtalk_user_bindings_ding_user_id` UNIQUE(`ding_user_id`) ) COMMENT='项目角色分工';
) COMMENT='钉钉用户绑定关系'; --> statement-breakpoint
--> statement-breakpoint
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE `project_initiations` ADD CONSTRAINT `project_initiations_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action; ALTER TABLE `project_initiations` ADD CONSTRAINT `project_initiations_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint --> statement-breakpoint
...@@ -184,7 +170,8 @@ ALTER TABLE `project_acceptances` ADD CONSTRAINT `project_acceptances_project_id ...@@ -184,7 +170,8 @@ ALTER TABLE `project_acceptances` ADD CONSTRAINT `project_acceptances_project_id
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE `project_timelines` ADD CONSTRAINT `project_timelines_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action; ALTER TABLE `project_timelines` ADD CONSTRAINT `project_timelines_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint --> statement-breakpoint
ALTER TABLE `project_approval_instances` ADD CONSTRAINT `project_approval_instances_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action; ALTER TABLE `project_role_assignments` ADD CONSTRAINT `project_role_assignments_project_id_project_list_id_fk` FOREIGN KEY (`project_id`) REFERENCES `project_list`(`id`) ON DELETE cascade ON UPDATE no action;
--> statement-breakpoint
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_products_status` ON `product_list` (`status`); CREATE INDEX `idx_products_status` ON `product_list` (`status`);
--> statement-breakpoint --> statement-breakpoint
...@@ -194,10 +181,6 @@ CREATE INDEX `idx_projects_stage_status` ON `project_list` (`stage`,`status`); ...@@ -194,10 +181,6 @@ CREATE INDEX `idx_projects_stage_status` ON `project_list` (`stage`,`status`);
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_projects_product_id` ON `project_list` (`product_id`); CREATE INDEX `idx_projects_product_id` ON `project_list` (`product_id`);
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_projects_owner_user_id` ON `project_list` (`owner_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_projects_owner_mobile` ON `project_list` (`owner_mobile`);
--> statement-breakpoint
CREATE INDEX `idx_projects_operator_user_id` ON `project_list` (`operator_user_id`); CREATE INDEX `idx_projects_operator_user_id` ON `project_list` (`operator_user_id`);
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_cases_product_id` ON `case_list` (`product_id`); CREATE INDEX `idx_cases_product_id` ON `case_list` (`product_id`);
...@@ -210,8 +193,11 @@ CREATE INDEX `idx_project_timelines_project_code` ON `project_timelines` (`proje ...@@ -210,8 +193,11 @@ CREATE INDEX `idx_project_timelines_project_code` ON `project_timelines` (`proje
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_project_timelines_created_at` ON `project_timelines` (`created_at`); CREATE INDEX `idx_project_timelines_created_at` ON `project_timelines` (`created_at`);
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_approval_instances_project_id` ON `project_approval_instances` (`project_id`); CREATE INDEX `idx_project_roles_project_id` ON `project_role_assignments` (`project_id`);
--> statement-breakpoint
CREATE INDEX `idx_project_roles_sso_id` ON `project_role_assignments` (`sso_id`);
--> statement-breakpoint
CREATE INDEX `idx_project_roles_project_role` ON `project_role_assignments` (`project_id`,`role`);
--> statement-breakpoint
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_approval_instances_project_code` ON `project_approval_instances` (`project_code`);
--> statement-breakpoint --> statement-breakpoint
CREATE INDEX `idx_approval_instances_status` ON `project_approval_instances` (`status`);
...@@ -13,10 +13,10 @@ ...@@ -13,10 +13,10 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dingtalk-stream": "^2.1.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-http-proxy": "^2.1.2",
"mongoose": "^8.23.1", "mongoose": "^8.23.1",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
...@@ -1629,17 +1629,6 @@ ...@@ -1629,17 +1629,6 @@
], ],
"license": "MIT" "license": "MIT"
}, },
"node_modules/dingtalk-stream": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/dingtalk-stream/-/dingtalk-stream-2.1.5.tgz",
"integrity": "sha512-6H3tSc/mE6hMj4RBB5ntkI4ycC498RobmtMxfLS8eBTRPjBZlhUdDYEHA0asOoTLSzC2PHqupr4D4HVoaU7bRQ==",
"license": "MIT",
"dependencies": {
"axios": "^1.4.0",
"debug": "^4.3.4",
"ws": "^8.13.0"
}
},
"node_modules/dotenv": { "node_modules/dotenv": {
"version": "16.6.1", "version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
...@@ -1877,6 +1866,12 @@ ...@@ -1877,6 +1866,12 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/es6-promise": {
"version": "4.2.8",
"resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-4.2.8.tgz",
"integrity": "sha512-HJDGx5daxeIvxdBxvG2cb9g4tEvwIk3i8+nhX0yGrYmZUzbkdg8QbDevheDB8gd0//uPj4c1EQua8Q+MViT0/w==",
"license": "MIT"
},
"node_modules/esbuild": { "node_modules/esbuild": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
...@@ -2252,6 +2247,56 @@ ...@@ -2252,6 +2247,56 @@
"url": "https://opencollective.com/express" "url": "https://opencollective.com/express"
} }
}, },
"node_modules/express-http-proxy": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/express-http-proxy/-/express-http-proxy-2.1.2.tgz",
"integrity": "sha512-FXcAcs7Nf/hF73Mzh0WDWPwaOlsEUL/fCHW3L4wU6DH79dypsaxmbnAildCLniFs7HQuuvoiR6bjNVUvGuTb5g==",
"license": "MIT",
"dependencies": {
"debug": "^3.0.1",
"es6-promise": "^4.1.1",
"raw-body": "^2.3.0"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/express-http-proxy/node_modules/debug": {
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.1"
}
},
"node_modules/express-http-proxy/node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/express-http-proxy/node_modules/raw-body": {
"version": "2.5.3",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
"integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.4.24",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/express/node_modules/mime-db": { "node_modules/express/node_modules/mime-db": {
"version": "1.54.0", "version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
...@@ -4537,27 +4582,6 @@ ...@@ -4537,27 +4582,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
"integrity": "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/yocto-queue": { "node_modules/yocto-queue": {
"version": "0.1.0", "version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
......
...@@ -7,12 +7,10 @@ ...@@ -7,12 +7,10 @@
"scripts": { "scripts": {
"start": "node src/index.js", "start": "node src/index.js",
"dev": "cross-env NODE_ENV=development node --watch src/index.js", "dev": "cross-env NODE_ENV=development node --watch src/index.js",
"dingtalk:stream": "node src/modules/dms/dingtalk/stream.js",
"db:generate": "drizzle-kit generate", "db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"lint": "eslint src/", "lint": "eslint src/",
"lint:fix": "eslint --fix src/", "lint:fix": "eslint --fix src/",
"deploy": "cross-env NODE_ENV=production SERVER_PORT=5101 pm2 start src/index.js -n ezijing-node-server", "deploy": "cross-env NODE_ENV=production SERVER_PORT=5101 DOTENV_CONFIG_PATH=.env.prod pm2 start src/index.js -n ezijing-node-server",
"reload": "pm2 reload ezijing-node-server" "reload": "pm2 reload ezijing-node-server"
}, },
"author": "King", "author": "King",
...@@ -31,10 +29,10 @@ ...@@ -31,10 +29,10 @@
"cors": "^2.8.5", "cors": "^2.8.5",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dingtalk-stream": "^2.1.5",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"drizzle-orm": "^0.45.2", "drizzle-orm": "^0.45.2",
"express": "^5.2.1", "express": "^5.2.1",
"express-http-proxy": "^2.1.2",
"mongoose": "^8.23.1", "mongoose": "^8.23.1",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pino": "^10.3.1", "pino": "^10.3.1",
......
import express from 'express' import express from 'express'
import cors from 'cors' import cors from 'cors'
import proxy from 'express-http-proxy'
import pinoHttp from 'pino-http' import pinoHttp from 'pino-http'
import logger from './lib/logger.js' import logger from './lib/logger.js'
import { notFound, errorHandler } from './middleware/error.js' import { notFound, errorHandler } from './middleware/error.js'
...@@ -18,6 +19,7 @@ app.use(pinoHttp({ logger, autoLogging: process.env.NODE_ENV !== 'test' })) ...@@ -18,6 +19,7 @@ app.use(pinoHttp({ logger, autoLogging: process.env.NODE_ENV !== 'test' }))
// Middleware // Middleware
app.use(cors({ credentials: true })) app.use(cors({ credentials: true }))
app.use('/api/usercenter', proxy('https://api-usercenter.ezijing.com'))
// 增加请求体大小限制,支持批量日志上报(默认 100kb,增加到 10mb) // 增加请求体大小限制,支持批量日志上报(默认 100kb,增加到 10mb)
app.use(express.json({ limit: '10mb' })) app.use(express.json({ limit: '10mb' }))
app.use(express.urlencoded({ extended: true, limit: '10mb' })) app.use(express.urlencoded({ extended: true, limit: '10mb' }))
...@@ -28,8 +30,8 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now() ...@@ -28,8 +30,8 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now()
// Modules // Modules
app.use(wechatRoutes) app.use(wechatRoutes)
app.use(wxChartRoutes) app.use(wxChartRoutes)
app.use(logsRoutes) app.use('/api', logsRoutes)
app.use(dmsRoutes) app.use('/api', dmsRoutes)
// Error handling // Error handling
app.use(notFound) app.use(notFound)
......
...@@ -16,27 +16,28 @@ const config = { ...@@ -16,27 +16,28 @@ const config = {
password: process.env.MYSQL_PASSWORD || '', password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'com_dms', database: process.env.MYSQL_DATABASE || 'com_dms',
}, },
dingtalk: { dmsAuth: {
appId: process.env.DINGTALK_APP_ID || '', disabled: process.env.DMS_AUTH_DISABLED === 'true',
corpId: process.env.DINGTALK_CORP_ID || '', ssoUserInfoUrl:
appKey: process.env.DINGTALK_APP_KEY || '', process.env.SSO_USER_INFO_URL || 'https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info',
appSecret: process.env.DINGTALK_APP_SECRET || '', userCenterBatchUrl:
agentId: process.env.DINGTALK_AGENT_ID || '', process.env.USER_CENTER_BATCH_URL || 'https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info',
streamDebug: process.env.DINGTALK_STREAM_DEBUG === 'true', userCenterSalt: process.env.USER_CENTER_SALT || '',
processCodes: { permissionApiUrl: process.env.PERMISSION_API_URL || '',
projectApproval: process.env.DINGTALK_PROJECT_APPROVAL_PROCESS_CODE || '', permissionAppId: process.env.PERMISSION_APP_ID || '',
contractApproval: process.env.DINGTALK_CONTRACT_APPROVAL_PROCESS_CODE || '', permissionAppSecret: process.env.PERMISSION_APP_SECRET || '',
acceptanceApproval: process.env.DINGTALK_ACCEPTANCE_APPROVAL_PROCESS_CODE || '', permissionCacheTtlSeconds: parseInt(process.env.PERMISSION_CACHE_TTL_SECONDS || '180', 10) || 180,
},
wechat: {
apps: {
wxd6109d07f6396e5c: 'd80a330735fc82f3fd6aba425481e8fd',
wxe1849e28e176c67b: '90072420a6b6941b7b8af38afd019b9f',
wxb9651d97aabb1a06: 'fc40521654d4a0157fb1bdf15dce760f',
wx451c01d40d090d7a: 'c740445d23458904dd7cc8f2e1bc2601',
wxb0280dd47666d2e9: 'f5f6e01dfa06da3a29b7fa710a287073',
wxfe67f37095e08d2f: '689d4d5a5c3b1290e8ba7cc6fe872b28',
}, },
}, },
wechat: { apps: {} },
}
// Load WeChat config from env
let i = 1
while (process.env[`WX_APPID_${i}`] && process.env[`WX_SECRET_${i}`]) {
config.wechat.apps[process.env[`WX_APPID_${i}`]] = process.env[`WX_SECRET_${i}`]
i++
} }
export default config export default config
import * as acceptancesService from './service.js'
import { success, error } from '../../../lib/response.js'
export const detail = async (req, res) => {
const acceptance = await acceptancesService.getByProject(req.params.projectCode)
if (acceptance === null) return error(res, '项目不存在或验收信息不存在', 404)
success(res, acceptance)
}
import { eq } from 'drizzle-orm'
import { db } from '../../../db/index.js'
import { acceptances, projects } from '../projects/schema.js'
const getProjectByCode = async (projectCode) => {
const rows = await db.select().from(projects).where(eq(projects.projectCode, projectCode)).limit(1)
return rows[0] || null
}
export const getByProject = async (projectCode) => {
const project = await getProjectByCode(projectCode)
if (!project) return null
const rows = await db.select().from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1)
return rows[0] || null
}
export const APPROVAL_KEYS = {
PROJECT: 'project_approval',
CONTRACT: 'contract_approval',
ACCEPTANCE: 'acceptance_approval',
}
export const APPROVAL_KEY_BY_TYPE = {
project: APPROVAL_KEYS.PROJECT,
contract: APPROVAL_KEYS.CONTRACT,
acceptance: APPROVAL_KEYS.ACCEPTANCE,
}
export const APPROVAL_TYPE_BY_KEY = {
[APPROVAL_KEYS.PROJECT]: 'project',
[APPROVAL_KEYS.CONTRACT]: 'contract',
[APPROVAL_KEYS.ACCEPTANCE]: 'acceptance',
}
export const APPROVAL_TITLE_BY_KEY = {
[APPROVAL_KEYS.PROJECT]: '立项审批',
[APPROVAL_KEYS.CONTRACT]: '合同审批',
[APPROVAL_KEYS.ACCEPTANCE]: '验收审批',
}
export const APPROVAL_STATUS_CODES = {
running: 10,
approved: 20,
rejected: 30,
terminated: 30,
deleted: 30,
failed: 30,
created: 10,
}
export const PROJECT_STAGE_BY_APPROVAL = {
[APPROVAL_KEYS.PROJECT]: {
pending: 20,
approved: 30,
},
[APPROVAL_KEYS.CONTRACT]: {
pending: 40,
approved: 50,
},
[APPROVAL_KEYS.ACCEPTANCE]: {
pending: 60,
approved: 70,
},
}
export const PROCESS_CODE_CONFIG_KEY = {
[APPROVAL_KEYS.PROJECT]: 'projectApproval',
[APPROVAL_KEYS.CONTRACT]: 'contractApproval',
[APPROVAL_KEYS.ACCEPTANCE]: 'acceptanceApproval',
}
import * as approvalsService from './service.js'
import { success } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const start = async (req, res) => {
const approval = await approvalsService.startApproval(
req.params.projectCode,
req.params.approvalType,
getCurrentUser(req),
)
success(res, approval, 201)
}
export const listByProject = async (req, res) => {
const approvals = await approvalsService.listProjectApprovals(req.params.projectCode)
success(res, approvals)
}
export const retry = async (req, res) => {
const approval = await approvalsService.retryApproval(req.params.id)
success(res, approval)
}
import { bigint, index, json, mysqlEnum, mysqlTable, text, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js'
import { projects } from '../projects/schema.js'
export const approvalInstances = mysqlTable(
'project_approval_instances',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
approvalKey: mysqlEnum('approval_key', ['project_approval', 'contract_approval', 'acceptance_approval']).notNull(),
processCode: varchar('process_code', { length: 128 }).notNull(),
processInstanceId: varchar('process_instance_id', { length: 128 }),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
projectCode: varchar('project_code', { length: 64 }).notNull(),
originatorUserId: varchar('originator_user_id', { length: 64 }),
originatorDingUserId: varchar('originator_ding_user_id', { length: 128 }).notNull(),
status: mysqlEnum('status', ['created', 'running', 'approved', 'rejected', 'terminated', 'deleted', 'failed']).notNull().default('created'),
formSnapshot: json('form_snapshot'),
resultSnapshot: json('result_snapshot'),
errorMessage: text('error_message'),
...timestamps,
},
(table) => ({
processInstanceIdIdx: uniqueIndex('uk_approval_instances_process_instance_id').on(table.processInstanceId),
projectIdIdx: index('idx_approval_instances_project_id').on(table.projectId),
projectCodeIdx: index('idx_approval_instances_project_code').on(table.projectCode),
statusIdx: index('idx_approval_instances_status').on(table.status),
})
)
import { desc, eq } from 'drizzle-orm'
import config from '../../../config.js'
import { httpError } from '../../../lib/http-error.js'
import {
APPROVAL_KEY_BY_TYPE,
APPROVAL_STATUS_CODES,
APPROVAL_TITLE_BY_KEY,
APPROVAL_TYPE_BY_KEY,
PROCESS_CODE_CONFIG_KEY,
PROJECT_STAGE_BY_APPROVAL,
} from './constants.js'
import * as dingtalkService from '../dingtalk/service.js'
import { db } from '../../../db/index.js'
import { approvalInstances } from './schema.js'
import { acceptances, contracts, initiations, projects, PROJECT_STATUSES } from '../projects/schema.js'
import { appendTimeline } from '../projects/service.js'
import { isApprovalStartAllowed } from '../projects/workflow.js'
const getProcessCode = (approvalKey) => {
const configKey = PROCESS_CODE_CONFIG_KEY[approvalKey]
const processCode = config.dingtalk.processCodes[configKey]
if (!processCode) {
throw httpError(500, `审批流程 ${approvalKey} 的 processCode 未配置`)
}
return processCode
}
const assertApprovalPreconditions = async (tx, approvalKey, project) => {
if (approvalKey === 'project_approval') {
const rows = await tx.select().from(initiations).where(eq(initiations.projectId, project.id)).limit(1)
const initiation = rows[0] || {}
if (!initiation.applicationFileUrl) {
throw httpError(400, '请先上传项目申报书,再发起立项审批')
}
return
}
if (approvalKey === 'contract_approval') {
const rows = await tx.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1)
const contract = rows[0] || {}
if (!contract.contractNo || !contract.contractName || !contract.archiveFileUrl) {
throw httpError(400, '请先补全合同编号、合同名称、合同文件,再发起合同审批')
}
return
}
if (approvalKey === 'acceptance_approval') {
const rows = await tx.select().from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1)
const acceptance = rows[0] || {}
if (!acceptance.acceptanceReportUrl) {
throw httpError(400, '请先上传验收报告,再发起验收审批')
}
}
}
const buildFormComponentValues = (project) => [
{ name: '关联项目编号', value: project.projectCode },
{ name: '项目名称', value: project.name },
{ name: '院校名称', value: project.schoolName || '' },
]
const getApprovalById = async (id) => {
const rows = await db.select().from(approvalInstances).where(eq(approvalInstances.id, Number(id))).limit(1)
return rows[0] || null
}
const mapApprovalRecord = (approval) => ({
id: approval.id,
type: APPROVAL_TYPE_BY_KEY[approval.approvalKey] || approval.approvalKey,
title: APPROVAL_TITLE_BY_KEY[approval.approvalKey] || approval.approvalKey,
status: APPROVAL_STATUS_CODES[approval.status] || APPROVAL_STATUS_CODES.running,
createdAt: approval.createdAt,
finishedAt: ['approved', 'rejected', 'terminated', 'deleted'].includes(approval.status) ? approval.updatedAt : null,
remark: approval.errorMessage || null,
})
const buildTimelineDescription = (approvalKey, status) => {
const title = APPROVAL_TITLE_BY_KEY[approvalKey] || approvalKey
return `${title}状态:${status}`
}
const getProjectStatusByApprovalStatus = (approvalKey, approvalStatus) => {
if (approvalStatus === 'running' || approvalStatus === 'created') return PROJECT_STATUSES.PENDING
if (approvalStatus === 'approved') {
return approvalKey === 'acceptance_approval' ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE
}
if (approvalStatus === 'rejected') return PROJECT_STATUSES.ACTIVE
if (approvalStatus === 'terminated') return PROJECT_STATUSES.ACTIVE
if (approvalStatus === 'deleted') return PROJECT_STATUSES.ACTIVE
return PROJECT_STATUSES.PENDING
}
export const startApproval = async (projectCode, approvalType, currentUser = {}) => {
const approvalKey = APPROVAL_KEY_BY_TYPE[approvalType]
if (!approvalKey) {
throw httpError(400, '审批类型不正确')
}
const processCode = getProcessCode(approvalKey)
let approvalId
let project
let formComponentValues
let originatorDingUserId
await db.transaction(async (tx) => {
const projectRows = await tx.select().from(projects).where(eq(projects.projectCode, projectCode)).limit(1)
project = projectRows[0]
if (!project) {
throw httpError(404, '项目不存在')
}
if (!isApprovalStartAllowed({ approvalKey, stage: project.stage, status: project.status })) {
throw httpError(400, '当前项目状态不允许发起该审批')
}
await assertApprovalPreconditions(tx, approvalKey, project)
originatorDingUserId = currentUser.dingUserId || project.ownerDingUserId
if (!originatorDingUserId) {
throw httpError(400, '缺少钉钉用户 userid,无法发起审批')
}
formComponentValues = buildFormComponentValues(project)
const [insertResult] = await tx.insert(approvalInstances).values({
approvalKey,
processCode,
projectId: project.id,
projectCode: project.projectCode,
originatorUserId: currentUser.userId || project.ownerUserId,
originatorDingUserId,
status: 'created',
formSnapshot: formComponentValues,
})
approvalId = insertResult.insertId
})
try {
const processInstanceId = await dingtalkService.createProcessInstance({
processCode,
originatorDingUserId,
formComponentValues,
})
await db.transaction(async (tx) => {
await tx
.update(approvalInstances)
.set({ processInstanceId, status: 'running', errorMessage: null })
.where(eq(approvalInstances.id, approvalId))
await tx
.update(projects)
.set({
stage: PROJECT_STAGE_BY_APPROVAL[approvalKey].pending,
status: PROJECT_STATUSES.PENDING,
operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null,
})
.where(eq(projects.id, project.id))
await appendTimeline(tx, project, {
title: '发起审批',
description: buildTimelineDescription(approvalKey, 'running'),
stage: PROJECT_STAGE_BY_APPROVAL[approvalKey].pending,
status: PROJECT_STATUSES.PENDING,
operatorName: currentUser.userName || project.ownerName || null,
})
})
const approval = await getApprovalById(approvalId)
return mapApprovalRecord(approval)
} catch (err) {
await db
.update(approvalInstances)
.set({ status: 'failed', errorMessage: err.message })
.where(eq(approvalInstances.id, approvalId))
throw err
}
}
export const listProjectApprovals = async (projectCode) => {
const rows = await db
.select()
.from(approvalInstances)
.where(eq(approvalInstances.projectCode, projectCode))
.orderBy(desc(approvalInstances.createdAt))
return rows.map(mapApprovalRecord)
}
export const retryApproval = async (id) => {
const approval = await getApprovalById(id)
if (!approval) {
throw httpError(404, '审批实例不存在')
}
if (approval.status !== 'failed') {
throw httpError(400, '只有失败的审批实例可以重试')
}
const processInstanceId = await dingtalkService.createProcessInstance({
processCode: approval.processCode,
originatorDingUserId: approval.originatorDingUserId,
formComponentValues: approval.formSnapshot,
})
await db
.update(approvalInstances)
.set({ processInstanceId, status: 'running', errorMessage: null })
.where(eq(approvalInstances.id, approval.id))
const nextApproval = await getApprovalById(id)
return mapApprovalRecord(nextApproval)
}
const normalizeEventValue = (value) => (value == null ? null : String(value).trim().toLowerCase())
const getFinalStatus = (event) => {
const type = normalizeEventValue(event.type)
const result = normalizeEventValue(event.result)
if (event.eventType === 'bpms_task_change') {
return type === 'finish' && result === 'refuse' ? 'rejected' : null
}
if (type === 'start') return 'running'
if (type === 'terminate') return 'terminated'
if (type === 'delete') return 'deleted'
if (type !== 'finish') return null
if (result === 'agree') return 'approved'
if (result === 'refuse') return 'rejected'
return null
}
export const handleApprovalEvent = async (event) => {
const eventId = event.eventId || null
const processInstanceId = event.processInstanceId
return db.transaction(async (tx) => {
if (!processInstanceId) {
return { handled: false, eventId, error: '回调事件缺少 processInstanceId' }
}
const nextStatus = getFinalStatus(event)
if (!nextStatus) {
return { handled: false, ignored: true, eventId }
}
const approvalRows = await tx
.select()
.from(approvalInstances)
.where(eq(approvalInstances.processInstanceId, processInstanceId))
.limit(1)
const approval = approvalRows[0]
if (!approval) {
return { handled: false, eventId }
}
const finishedStatuses = ['approved', 'rejected', 'terminated', 'deleted']
if (nextStatus === 'running' && finishedStatuses.includes(approval.status)) {
return { handled: false, ignored: true, eventId, status: approval.status }
}
if (approval.status === nextStatus) return { handled: false, ignored: true, eventId, status: approval.status }
const projectStatus = getProjectStatusByApprovalStatus(approval.approvalKey, nextStatus)
const projectStage =
nextStatus === 'approved'
? PROJECT_STAGE_BY_APPROVAL[approval.approvalKey].approved
: PROJECT_STAGE_BY_APPROVAL[approval.approvalKey].pending
await tx
.update(approvalInstances)
.set({ status: nextStatus, resultSnapshot: event, errorMessage: null })
.where(eq(approvalInstances.id, approval.id))
await tx.update(projects).set({ stage: projectStage, status: projectStatus }).where(eq(projects.id, approval.projectId))
const projectRows = await tx.select().from(projects).where(eq(projects.id, approval.projectId)).limit(1)
if (projectRows[0]) {
await appendTimeline(tx, projectRows[0], {
title: nextStatus === 'running' ? '审批状态更新' : '审批结束',
description: buildTimelineDescription(approval.approvalKey, nextStatus),
stage: projectStage,
status: projectStatus,
operatorName: null,
})
}
return { handled: true, eventId, status: nextStatus }
})
}
import { success } from '../../../lib/response.js'
import { getApplicationUsers } from './permission.client.js'
import { getTgc } from './auth.service.js'
export const me = (req, res) => success(res, {
user: {
ssoId: req.user.ssoId,
id: req.user.ssoId,
name: req.user.name,
mobile: req.user.mobile,
avatar: req.user.avatar,
},
roles: req.user.roles,
routes: req.user.routes,
})
export const users = async (req, res) => {
const result = await getApplicationUsers(getTgc(req), {
page: req.query.page || 1,
limit: req.query.limit || 100,
...(req.query.ssoId ? { sso_id: req.query.ssoId } : {}),
})
success(res, result)
}
import { authenticateRequest } from './auth.service.js'
import { hasAnyDmsRouteAccess, hasRouteAccess } from './route-access.js'
export const authenticate = async (req, _res, next) => {
try {
req.user = await authenticateRequest(req)
next()
} catch (error) {
next(error)
}
}
export const requireAnyDmsRouteAccess = (req, _res, next) => {
if (hasAnyDmsRouteAccess(req.user)) return next()
const error = new Error('无权访问 DMS 模块')
error.statusCode = 403
next(error)
}
export const requireRouteAccess = (routePrefix) => (req, _res, next) => {
if (hasRouteAccess(req.user, routePrefix)) return next()
const error = new Error('无权访问当前 DMS 模块')
error.statusCode = 403
next(error)
}
export const hasRole = (user, role) => user?.roles?.includes('admin') || user?.roles?.includes(role)
export const requireRole = (...roles) => (req, _res, next) => {
if (req.user?.roles?.includes('admin') || roles.some((role) => hasRole(req.user, role))) return next()
const error = new Error('当前角色无权执行该操作')
error.statusCode = 403
next(error)
}
import axios from 'axios'
import config from '../../../config.js'
import { getUserAuthorization } from './permission.client.js'
import { hasAnyDmsRouteAccess, normalizeRoute } from './route-access.js'
const roleTag = (role) => role?.tag
export const getTgc = (req) => {
const cookies = req.headers.cookie || ''
const item = cookies.split(';').map((part) => part.trim()).find((part) => part.startsWith('TGC='))
return item ? decodeURIComponent(item.slice(4)) : ''
}
const normalizeSsoUser = (payload) => {
const body = payload?.data
if (payload?.code !== 0 || !body?.id) return null
return {
ssoId: String(body.id),
name: body.realname || body.nickname || body.username || '',
mobile: body?.mobile ?? '',
avatar: body?.avatar ?? '',
}
}
export const authenticateRequest = async (req) => {
if (config.dmsAuth.disabled) {
return { ssoId: 'local-dev', name: '本地开发用户', mobile: '', roles: ['admin'], routes: [] }
}
const tgc = getTgc(req)
if (!tgc) {
const error = new Error('登录状态已失效')
error.statusCode = 401
throw error
}
let response
try {
response = await axios.get(config.dmsAuth.ssoUserInfoUrl, {
timeout: 5000,
headers: { Cookie: `TGC=${encodeURIComponent(tgc)}` },
})
} catch (cause) {
const error = new Error(cause.response?.status === 401 ? '登录状态已失效' : '统一登录服务暂时不可用')
error.statusCode = cause.response?.status === 401 ? 401 : 503
throw error
}
const user = normalizeSsoUser(response.data)
if (!user) {
const error = new Error('登录状态已失效')
error.statusCode = 401
throw error
}
let authorization
try {
authorization = await getUserAuthorization(user.ssoId, tgc)
} catch (cause) {
if (cause.response?.status === 401 || cause.response?.status === 403) {
const error = new Error('当前账号未开通交付管理系统权限')
error.statusCode = 403
throw error
}
throw cause
}
const authenticatedUser = {
...user,
roles: authorization.roles.map(roleTag).filter(Boolean).map(String),
routes: authorization.routes.map(normalizeRoute).filter(Boolean),
}
const canAccess = hasAnyDmsRouteAccess(authenticatedUser)
if (!canAccess) {
const error = new Error('当前账号未开通交付管理系统权限')
error.statusCode = 403
throw error
}
return authenticatedUser
}
import crypto from 'node:crypto'
const normalizeValue = (value) => {
if (Array.isArray(value) || (value && typeof value === 'object')) return JSON.stringify(value)
if (value === null || value === undefined) return ''
return String(value)
}
export const createPermissionSignature = (params, appSecret, timestamp, nonce) => {
const payload = { ...params, timestamp, nonce, salt: appSecret }
const query = Object.keys(payload)
.sort()
.map((key) => `${key}=${normalizeValue(payload[key])}`)
.join('&')
return crypto.createHash('md5').update(query).digest('hex').toUpperCase()
}
import crypto from 'node:crypto'
import axios from 'axios'
import config from '../../../config.js'
import { createPermissionSignature } from './permission-sign.js'
import { getUserProfiles } from './user-center.client.js'
const cache = new Map()
const unwrapItems = (payload) => payload?.data?.items ?? []
const requestRaw = async (path, tgc, params = {}) => {
const { permissionApiUrl, permissionAppId, permissionAppSecret } = config.dmsAuth
if (!permissionApiUrl || !permissionAppId || !permissionAppSecret) {
const error = new Error('权限中心配置不完整')
error.statusCode = 503
throw error
}
const timestamp = Math.floor(Date.now() / 1000)
const nonce = crypto.randomUUID().replaceAll('-', '_')
const signature = createPermissionSignature(params, permissionAppSecret, timestamp, nonce)
const response = await axios.get(`${permissionApiUrl.replace(/\/$/, '')}${path}`, {
params,
timeout: 5000,
headers: {
Cookie: `TGC=${encodeURIComponent(tgc)}`,
'secret-id': permissionAppId,
'secret-key': permissionAppSecret,
timestamp: String(timestamp),
nonce,
signature,
},
})
return response.data
}
const request = async (path, tgc, params = {}) => unwrapItems(await requestRaw(path, tgc, params))
export const getUserAuthorization = async (ssoId, tgc) => {
const cached = cache.get(ssoId)
if (cached && cached.expiresAt > Date.now()) return cached.value
const [roles, routes] = await Promise.all([
request('/api/v1/user/roles', tgc),
request('/api/v1/user/routes', tgc),
])
const value = {
roles: Array.isArray(roles) ? roles : [],
routes: Array.isArray(routes) ? routes : [],
}
cache.set(ssoId, {
value,
expiresAt: Date.now() + config.dmsAuth.permissionCacheTtlSeconds * 1000,
})
return value
}
export const getApplicationUsers = async (tgc, params = {}) => {
const payload = await requestRaw('/api/v1/app/users', tgc, params)
const result = payload?.data ?? {}
const items = Array.isArray(result?.data) ? result.data : []
if (!items.length) return result
let profiles = {}
try {
profiles = await getUserProfiles(items.map((item) => item.sso_id))
} catch (error) {
console.warn('用户中心批量查询失败,成员列表将保留权限中心数据', error.message)
}
return {
...result,
data: items.map((item) => {
const ssoId = String(item.sso_id ?? '')
const profile = profiles[ssoId] ?? {}
const realname = profile.realname ?? ''
const nickname = profile.nickname ?? ''
const username = profile.username ?? ''
return {
...item,
name: realname || nickname || username || item.name || ssoId,
realname,
nickname,
username,
mobile: profile.mobile ?? item.mobile ?? '',
avatar: profile.avatar ?? item.avatar ?? '',
}
}),
}
}
export const normalizeRoute = (value) => {
const raw = String(value ?? '').trim()
const normalized = raw.split('?')[0].replace(/\/+$/, '')
return normalized || '/'
}
export const routeMatches = (allowedRoute, requestPath) => {
const allowed = normalizeRoute(allowedRoute)
const requested = normalizeRoute(requestPath)
return requested === allowed || requested.startsWith(`${allowed}/`)
}
export const hasRouteAccess = (user, requestPath) => {
if (user?.roles?.includes('admin')) return true
return user?.routes?.some((route) => routeMatches(route, requestPath)) ?? false
}
export const hasAnyDmsRouteAccess = (user) => {
if (user?.roles?.includes('admin')) return true
return user?.routes?.some((route) => normalizeRoute(route).startsWith('/dms/')) ?? false
}
import crypto from 'node:crypto'
import axios from 'axios'
import config from '../../../config.js'
const MAX_BATCH_SIZE = 500
const normalizeValue = (value) => {
if (Array.isArray(value) || (value && typeof value === 'object')) return JSON.stringify(value)
if (value === null || value === undefined) return ''
return String(value)
}
const createUserCenterSignature = (params, salt) => {
const payload = { ...params, salt }
const query = Object.keys(payload)
.sort()
.map((key) => `${key}=${normalizeValue(payload[key])}`)
.join('&')
return crypto.createHash('md5').update(query).digest('hex').toUpperCase()
}
const splitIntoBatches = (items) => {
const batches = []
for (let index = 0; index < items.length; index += MAX_BATCH_SIZE) {
batches.push(items.slice(index, index + MAX_BATCH_SIZE))
}
return batches
}
export const getUserProfiles = async (ssoIds) => {
const ids = [...new Set(ssoIds.filter(Boolean).map(String))]
const { userCenterBatchUrl, userCenterSalt } = config.dmsAuth
if (!ids.length) return {}
if (!userCenterBatchUrl || !userCenterSalt) {
console.warn('用户中心批量查询未配置 USER_CENTER_BATCH_URL 或 USER_CENTER_SALT')
return {}
}
const profiles = {}
for (const batch of splitIntoBatches(ids)) {
const timestamp = Math.floor(Date.now() / 1000)
const nonce = crypto.randomUUID().replaceAll('-', '_')
const params = {
id: batch.join(','),
need_cache: 1,
need_encrypt: 0,
timestamp,
nonce,
}
const signature = createUserCenterSignature(params, userCenterSalt)
const response = await axios.get(userCenterBatchUrl, {
params: { ...params, signature },
timeout: 5000,
})
const payload = response.data
if (payload?.code !== 0) {
throw new Error(payload.msg || '用户中心查询失败')
}
const userMap = payload.data
if (userMap && typeof userMap === 'object' && !Array.isArray(userMap)) {
Object.assign(profiles, userMap)
}
}
return profiles
}
...@@ -14,14 +14,6 @@ const parseFiles = (row) => { ...@@ -14,14 +14,6 @@ const parseFiles = (row) => {
const rowsToList = (rows) => rows.map(parseFiles) const rowsToList = (rows) => rows.map(parseFiles)
const normalizePayload = (payload = {}) => ({
name: typeof payload.name === 'string' ? payload.name.trim() : '',
description: typeof payload.description === 'string' ? payload.description.trim() : '',
productId: payload.productId ? Number(payload.productId) : null,
productName: typeof payload.productName === 'string' ? payload.productName.trim() : '',
files: Array.isArray(payload.files) ? JSON.stringify(payload.files) : '[]',
})
const resolveOperator = (currentUser = {}) => ({ const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null, operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null, operatorName: currentUser.userName || null,
...@@ -70,7 +62,7 @@ export const getCaseById = async (id) => { ...@@ -70,7 +62,7 @@ export const getCaseById = async (id) => {
} }
export const createCase = async (payload = {}, currentUser = {}) => { export const createCase = async (payload = {}, currentUser = {}) => {
const values = normalizePayload(payload) const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
validate(values) validate(values)
const [result] = await db.insert(cases).values({ const [result] = await db.insert(cases).values({
...values, ...values,
...@@ -84,7 +76,7 @@ export const updateCase = async (id, payload = {}, currentUser = {}) => { ...@@ -84,7 +76,7 @@ export const updateCase = async (id, payload = {}, currentUser = {}) => {
const record = await getCaseById(id) const record = await getCaseById(id)
if (!record) return null if (!record) return null
const values = normalizePayload(payload) const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
validate(values) validate(values)
await db await db
.update(cases) .update(cases)
......
import * as deliveriesService from './service.js'
import { success, error } from '../../../lib/response.js'
export const detail = async (req, res) => {
const delivery = await deliveriesService.getByProject(req.params.projectCode)
if (delivery === null) return error(res, '项目不存在或交付信息不存在', 404)
success(res, delivery)
}
import { eq } from 'drizzle-orm'
import { db } from '../../../db/index.js'
import { deliveries, projects } from '../projects/schema.js'
const getProjectByCode = async (projectCode) => {
const rows = await db.select().from(projects).where(eq(projects.projectCode, projectCode)).limit(1)
return rows[0] || null
}
export const getByProject = async (projectCode) => {
const project = await getProjectByCode(projectCode)
if (!project) return null
const rows = await db.select().from(deliveries).where(eq(deliveries.projectId, project.id)).limit(1)
return rows[0] || null
}
import * as dingtalkService from './service.js'
import { success } from '../../../lib/response.js'
export const configStatus = (_req, res) => {
success(res, dingtalkService.getConfigStatus())
}
import { bigint, mysqlTable, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js'
export const dingtalkUserBindings = mysqlTable(
'dingtalk_user_bindings',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
systemUserId: varchar('system_user_id', { length: 64 }).notNull(),
dingUserId: varchar('ding_user_id', { length: 128 }).notNull(),
name: varchar('name', { length: 120 }),
mobile: varchar('mobile', { length: 64 }),
...timestamps,
},
(table) => ({
systemUserIdIdx: uniqueIndex('uk_dingtalk_user_bindings_system_user_id').on(table.systemUserId),
dingUserIdIdx: uniqueIndex('uk_dingtalk_user_bindings_ding_user_id').on(table.dingUserId),
})
)
import axios from 'axios'
import config from '../../../config.js'
import { httpError } from '../../../lib/http-error.js'
let tokenCache = {
accessToken: '',
expiresAt: 0,
}
const DINGTALK_API_BASE = 'https://api.dingtalk.com'
const ensureConfigured = () => {
if (!config.dingtalk.appKey || !config.dingtalk.appSecret) {
throw httpError(500, '钉钉应用 appKey/appSecret 未配置')
}
}
export const getAccessToken = async () => {
ensureConfigured()
const now = Date.now()
if (tokenCache.accessToken && tokenCache.expiresAt > now) {
return tokenCache.accessToken
}
const { data } = await axios.post(`${DINGTALK_API_BASE}/v1.0/oauth2/accessToken`, {
appKey: config.dingtalk.appKey,
appSecret: config.dingtalk.appSecret,
})
if (!data.accessToken) {
throw httpError(500, `获取钉钉 accessToken 失败: ${JSON.stringify(data)}`)
}
tokenCache = {
accessToken: data.accessToken,
expiresAt: now + Math.max((data.expireIn || 7200) - 300, 60) * 1000,
}
return tokenCache.accessToken
}
export const getConfigStatus = () => ({
appIdConfigured: Boolean(config.dingtalk.appId),
agentIdConfigured: Boolean(config.dingtalk.agentId),
appKeyConfigured: Boolean(config.dingtalk.appKey),
appSecretConfigured: Boolean(config.dingtalk.appSecret),
corpIdConfigured: Boolean(config.dingtalk.corpId),
streamMode: true,
processCodes: {
projectApprovalConfigured: Boolean(config.dingtalk.processCodes.projectApproval),
contractApprovalConfigured: Boolean(config.dingtalk.processCodes.contractApproval),
acceptanceApprovalConfigured: Boolean(config.dingtalk.processCodes.acceptanceApproval),
},
})
export const createProcessInstance = async ({ processCode, originatorDingUserId, formComponentValues }) => {
const accessToken = await getAccessToken()
const { data } = await axios.post(
`${DINGTALK_API_BASE}/v1.0/workflow/processInstances`,
{
processCode,
originatorUserId: originatorDingUserId,
deptId: -1,
formComponentValues,
},
{
headers: {
'x-acs-dingtalk-access-token': accessToken,
},
}
)
if (!data.instanceId) {
throw httpError(500, `创建钉钉审批实例失败: ${JSON.stringify(data)}`)
}
return data.instanceId
}
export const getProcessInstance = async (processInstanceId) => {
const accessToken = await getAccessToken()
const { data } = await axios.get(`${DINGTALK_API_BASE}/v1.0/workflow/processInstances/${processInstanceId}`, {
headers: {
'x-acs-dingtalk-access-token': accessToken,
},
})
return data
}
export const getUserIdByMobile = async (mobile) => {
const accessToken = await getAccessToken()
const { data } = await axios.post(
`https://oapi.dingtalk.com/topapi/v2/user/getbymobile?access_token=${accessToken}`,
{ mobile },
)
if (data.errcode && data.errcode !== 0) {
throw httpError(500, `未能通过手机号获取钉钉 userid: ${data.errmsg || JSON.stringify(data)}`)
}
if (!data.result?.userid) {
throw httpError(500, `未能通过手机号获取钉钉 userid: ${JSON.stringify(data)}`)
}
return data.result.userid
}
import { DWClient, EventAck } from 'dingtalk-stream'
import config from '../../../config.js'
import logger from '../../../lib/logger.js'
import { handleApprovalEvent } from '../approvals/service.js'
const ensureStreamConfig = () => {
if (!config.dingtalk.appKey || !config.dingtalk.appSecret) {
throw new Error('钉钉 Stream 模式需要配置 DINGTALK_APP_KEY 和 DINGTALK_APP_SECRET')
}
}
export const createDingTalkStreamClient = () => {
ensureStreamConfig()
const client = new DWClient({
clientId: config.dingtalk.appKey,
clientSecret: config.dingtalk.appSecret,
})
client.registerAllEventListener((message) => {
try {
const data = JSON.parse(message.data)
logger.info(
{
eventType: message.headers.eventType,
eventId: message.headers.eventId,
type: data.type,
result: data.result,
processInstanceId: data.processInstanceId,
},
'Received DingTalk stream event',
)
if (message.headers.eventType === 'bpms_instance_change') {
const event = {
eventId: message.headers.eventId,
eventType: message.headers.eventType,
processInstanceId: data.processInstanceId,
processCode: data.processCode,
type: data.type,
result: data.result,
payload: data,
streamHeaders: message.headers,
}
void handleApprovalEvent(event).catch((err) => {
logger.error({ err, event }, 'Failed to handle DingTalk stream event')
})
}
} catch (err) {
logger.error({ err, message }, 'Failed to parse DingTalk stream event')
}
return { status: EventAck.SUCCESS }
})
return client
}
export const startDingTalkStream = async () => {
const client = createDingTalkStreamClient()
await client.connect()
logger.info('DingTalk stream client started')
const shutdown = (signal) => {
logger.info(`${signal} received, disconnecting DingTalk stream client...`)
client.disconnect()
process.exit(0)
}
process.on('SIGTERM', () => shutdown('SIGTERM'))
process.on('SIGINT', () => shutdown('SIGINT'))
return client
}
if (import.meta.url === `file://${process.argv[1]}`) {
startDingTalkStream().catch((err) => {
logger.error({ err }, 'Failed to start DingTalk stream client')
process.exit(1)
})
}
import * as userBindingsService from './user-bindings.service.js'
import { success, error } from '../../../lib/response.js'
export const detail = async (req, res) => {
const binding = await userBindingsService.getBindingBySystemUserId(req.params.systemUserId)
if (!binding) return error(res, '钉钉用户绑定不存在', 404)
success(res, binding)
}
export const upsert = async (req, res) => {
const binding = await userBindingsService.upsertBinding(req.body)
success(res, binding)
}
import { eq } from 'drizzle-orm'
import { db } from '../../../db/index.js'
import { dingtalkUserBindings } from './schema.js'
import { getUserIdByMobile } from './service.js'
export const getBindingBySystemUserId = async (systemUserId) => {
const rows = await db
.select()
.from(dingtalkUserBindings)
.where(eq(dingtalkUserBindings.systemUserId, systemUserId))
.limit(1)
return rows[0] || null
}
export const upsertBinding = async (payload = {}) => {
const values = {
systemUserId: payload.systemUserId,
dingUserId: payload.dingUserId,
name: payload.name || null,
mobile: payload.mobile || null,
}
await db
.insert(dingtalkUserBindings)
.values(values)
.onDuplicateKeyUpdate({
set: {
dingUserId: values.dingUserId,
name: values.name,
mobile: values.mobile,
},
})
return getBindingBySystemUserId(payload.systemUserId)
}
export const resolveBindingByMobile = async ({ systemUserId, mobile, name }) => {
const dingUserId = await getUserIdByMobile(mobile)
if (systemUserId) {
return upsertBinding({
systemUserId,
dingUserId,
name,
mobile,
})
}
return {
systemUserId: null,
dingUserId,
name: name || null,
mobile,
}
}
import { Router } from 'express' import { Router } from 'express'
import { validate } from '../../lib/validate.js' import { validate } from '../../lib/validate.js'
import * as projectsController from './projects/controller.js' import * as projectsController from './projects/controller.js'
import * as approvalsController from './approvals/controller.js'
import * as dingtalkController from './dingtalk/controller.js'
import * as deliveriesController from './deliveries/controller.js'
import * as acceptancesController from './acceptances/controller.js'
import * as userBindingsController from './dingtalk/user-bindings.controller.js'
import * as productsController from './products/controller.js' import * as productsController from './products/controller.js'
import * as casesController from './cases/controller.js' import * as casesController from './cases/controller.js'
import * as authController from './auth/auth.controller.js'
import {
authenticate,
requireAnyDmsRouteAccess,
requireRouteAccess,
requireRole,
} from './auth/auth.middleware.js'
import * as projectRolesController from './project-roles/controller.js'
import { requireProjectAccess, requireProjectRole } from './project-roles/access.middleware.js'
import { import {
acceptanceUpsertBody, acceptanceUpsertBody,
approvalInstanceIdParam,
caseCreateBody, caseCreateBody,
casesListQuery, casesListQuery,
caseUpdateBody, caseUpdateBody,
...@@ -25,82 +28,87 @@ import { ...@@ -25,82 +28,87 @@ import {
productUpdateBody, productUpdateBody,
productUpdateStatusBody, productUpdateStatusBody,
projectCodeParam, projectCodeParam,
projectCodeWithApprovalTypeParam,
projectCreateBody, projectCreateBody,
projectsListQuery, projectsListQuery,
projectUpdateBody, projectUpdateBody,
systemUserIdParam, projectTeamBody,
userBindingUpsertBody, rollbackStageBody,
solutionUpsertBody,
} from './dms.validations.js' } from './dms.validations.js'
const router = Router() const router = Router()
router.get('/dms/auth/me', authenticate, authController.me)
router.use('/dms', authenticate)
router.get('/dms/system/users', requireRouteAccess('/dms/projects'), authController.users)
// ── products ── // ── products ──
router.get('/dms/products', validate({ query: productsListQuery }), productsController.list) router.get(
router.post('/dms/products', validate({ body: productCreateBody }), productsController.create) '/dms/products',
router.put('/dms/products/:id', validate({ params: idParam, body: productUpdateBody }), productsController.update) requireAnyDmsRouteAccess,
router.put('/dms/products/:id/status', validate({ params: idParam, body: productUpdateStatusBody }), productsController.updateStatus) validate({ query: productsListQuery }),
router.delete('/dms/products/:id', validate({ params: idParam }), productsController.remove) productsController.list,
)
const productsManageRouter = Router()
productsManageRouter.post('/', validate({ body: productCreateBody }), productsController.create)
productsManageRouter.put('/:id', validate({ params: idParam, body: productUpdateBody }), productsController.update)
productsManageRouter.put('/:id/status', validate({ params: idParam, body: productUpdateStatusBody }), productsController.updateStatus)
productsManageRouter.delete('/:id', validate({ params: idParam }), productsController.remove)
router.use('/dms/products', requireRouteAccess('/dms/products'), productsManageRouter)
// ── cases ── // ── cases ──
router.get('/dms/cases', validate({ query: casesListQuery }), casesController.list) const casesRouter = Router()
router.post('/dms/cases', validate({ body: caseCreateBody }), casesController.create) casesRouter.get('/', validate({ query: casesListQuery }), casesController.list)
router.get('/dms/cases/:id', validate({ params: idParam }), casesController.detail) casesRouter.post('/', validate({ body: caseCreateBody }), casesController.create)
router.put('/dms/cases/:id', validate({ params: idParam, body: caseUpdateBody }), casesController.update) casesRouter.get('/:id', validate({ params: idParam }), casesController.detail)
router.delete('/dms/cases/:id', validate({ params: idParam }), casesController.remove) casesRouter.put('/:id', validate({ params: idParam, body: caseUpdateBody }), casesController.update)
casesRouter.delete('/:id', validate({ params: idParam }), casesController.remove)
router.use('/dms/cases', requireRouteAccess('/dms/cases'), casesRouter)
// ── projects ── // ── projects ──
router.post('/dms/projects', validate({ body: projectCreateBody }), projectsController.create) const projectsRouter = Router()
router.get('/dms/projects', validate({ query: projectsListQuery }), projectsController.list) projectsRouter.post('/', validate({ body: projectCreateBody }), projectsController.create)
router.get('/dms/projects/:projectCode', validate({ params: projectCodeParam }), projectsController.detail) projectsRouter.get('/', validate({ query: projectsListQuery }), projectsController.list)
router.put('/dms/projects/:projectCode', validate({ params: projectCodeParam, body: projectUpdateBody }), projectsController.update) projectsRouter.get('/:projectCode', validate({ params: projectCodeParam }), requireProjectAccess, projectsController.detail)
router.delete('/dms/projects/:projectCode', validate({ params: projectCodeParam }), projectsController.remove) projectsRouter.put('/:projectCode', validate({ params: projectCodeParam, body: projectUpdateBody }), requireProjectRole('project_manager'), projectsController.update)
router.put('/dms/projects/:projectCode/stage', validate({ params: projectCodeParam, body: moveStageBody }), projectsController.moveStage) projectsRouter.delete('/:projectCode', requireRole('admin'), validate({ params: projectCodeParam }), projectsController.remove)
projectsRouter.put('/:projectCode/stage', validate({ params: projectCodeParam, body: moveStageBody }), requireProjectAccess, projectsController.moveStage)
projectsRouter.put('/:projectCode/stage/rollback', validate({ params: projectCodeParam, body: rollbackStageBody }), requireProjectRole('project_manager'), projectsController.rollbackStage)
projectsRouter.put('/:projectCode/solution', validate({ params: projectCodeParam, body: solutionUpsertBody }), requireProjectRole('solution_owner', 'project_manager'), projectsController.updateSolution)
router.put( projectsRouter.get('/:projectCode/team', validate({ params: projectCodeParam }), requireProjectAccess, projectRolesController.list)
'/dms/projects/:projectCode/initiation', projectsRouter.put('/:projectCode/team', validate({ params: projectCodeParam, body: projectTeamBody }), requireProjectRole('project_manager'), projectRolesController.replace)
projectsRouter.put(
'/:projectCode/initiation',
validate({ params: projectCodeParam, body: initiationUpsertBody }), validate({ params: projectCodeParam, body: initiationUpsertBody }),
requireProjectRole('initiation_owner', 'project_manager'),
projectsController.upsertInitiation, projectsController.upsertInitiation,
) )
router.put( projectsRouter.put(
'/dms/projects/:projectCode/procurement', '/:projectCode/procurement',
validate({ params: projectCodeParam, body: procurementUpsertBody }), validate({ params: projectCodeParam, body: procurementUpsertBody }),
requireProjectRole('procurement_owner', 'project_manager'),
projectsController.upsertProcurement, projectsController.upsertProcurement,
) )
router.put( projectsRouter.put(
'/dms/projects/:projectCode/contract', '/:projectCode/contract',
validate({ params: projectCodeParam, body: contractUpsertBody }), validate({ params: projectCodeParam, body: contractUpsertBody }),
requireProjectRole('contract_owner', 'project_manager'),
projectsController.upsertContract, projectsController.upsertContract,
) )
router.put( projectsRouter.put(
'/dms/projects/:projectCode/delivery', '/:projectCode/delivery',
validate({ params: projectCodeParam, body: deliveryUpsertBody }), validate({ params: projectCodeParam, body: deliveryUpsertBody }),
requireProjectRole('delivery_owner', 'project_manager'),
projectsController.upsertDelivery, projectsController.upsertDelivery,
) )
router.put( projectsRouter.put(
'/dms/projects/:projectCode/acceptance', '/:projectCode/acceptance',
validate({ params: projectCodeParam, body: acceptanceUpsertBody }), validate({ params: projectCodeParam, body: acceptanceUpsertBody }),
requireProjectRole('acceptance_owner', 'project_manager'),
projectsController.upsertAcceptance, projectsController.upsertAcceptance,
) )
router.use('/dms/projects', requireRouteAccess('/dms/projects'), projectsRouter)
// ── deliveries ──
router.get('/dms/projects/:projectCode/delivery', validate({ params: projectCodeParam }), deliveriesController.detail)
// ── acceptances ──
router.get('/dms/projects/:projectCode/acceptance', validate({ params: projectCodeParam }), acceptancesController.detail)
// ── approvals ──
router.post(
'/dms/projects/:projectCode/approvals/:approvalType',
validate({ params: projectCodeWithApprovalTypeParam }),
approvalsController.start,
)
router.get('/dms/projects/:projectCode/approvals', validate({ params: projectCodeParam }), approvalsController.listByProject)
router.post('/dms/approval-instances/:id/retry', validate({ params: approvalInstanceIdParam }), approvalsController.retry)
// ── dingtalk ──
router.get('/dms/dingtalk/user-bindings/:systemUserId', validate({ params: systemUserIdParam }), userBindingsController.detail)
router.put('/dms/dingtalk/user-bindings', validate({ body: userBindingUpsertBody }), userBindingsController.upsert)
router.get('/dms/dingtalk/config-status', dingtalkController.configStatus)
export default router export default router
...@@ -7,6 +7,27 @@ const pagination = { ...@@ -7,6 +7,27 @@ const pagination = {
limit: z.coerce.number().int().min(1).max(500).optional(), limit: z.coerce.number().int().min(1).max(500).optional(),
} }
const optionalNumber = z.preprocess(
(value) => value === null || value === '' ? undefined : value,
z.coerce.number().optional(),
)
const optionalId = z.preprocess(
(value) => value === '' ? undefined : value,
z.coerce.number().int().positive().nullish(),
)
const fileListValue = z.string().nullish()
const caseFile = z.object({
type: z.string().min(1).max(16),
type_name: z.string().max(120).optional(),
name: z.string().min(1).max(255),
url: z.string().min(1).max(1000),
created_at: z.string().optional(),
updated_at: z.string().optional(),
})
// ── products ───────────────────────────────────────────────────────── // ── products ─────────────────────────────────────────────────────────
export const productsListQuery = z.object({ export const productsListQuery = z.object({
...@@ -15,15 +36,14 @@ export const productsListQuery = z.object({ ...@@ -15,15 +36,14 @@ export const productsListQuery = z.object({
...pagination, ...pagination,
}) })
export const productCreateBody = z.object({ const productBody = z.object({
name: z.string().min(1).max(120), name: z.string().trim().min(1).max(120),
description: z.string().optional(), status: z.coerce.number().int().optional(),
description: z.string().trim().optional(),
}) })
export const productUpdateBody = z.object({ export const productCreateBody = productBody
name: z.string().min(1).max(120).optional(), export const productUpdateBody = productBody
description: z.string().optional(),
})
export const productUpdateStatusBody = z.object({ export const productUpdateStatusBody = z.object({
status: z.number().int(), status: z.number().int(),
...@@ -41,15 +61,16 @@ export const casesListQuery = z.object({ ...@@ -41,15 +61,16 @@ export const casesListQuery = z.object({
...pagination, ...pagination,
}) })
export const caseCreateBody = z.object({ const caseBody = z.object({
name: z.string().min(1).max(255), name: z.string().trim().min(1).max(255),
description: z.string().optional(), description: z.string().trim().optional(),
productId: z.coerce.number().int().optional(), productId: optionalId,
productName: z.string().max(255).optional(), productName: z.string().max(255).optional(),
files: z.array(z.string()).optional(), files: z.array(caseFile).optional(),
}) })
export const caseUpdateBody = caseCreateBody.partial() export const caseCreateBody = caseBody
export const caseUpdateBody = caseBody
// ── projects ───────────────────────────────────────────────────────── // ── projects ─────────────────────────────────────────────────────────
...@@ -57,7 +78,6 @@ export const projectsListQuery = z.object({ ...@@ -57,7 +78,6 @@ export const projectsListQuery = z.object({
keyword: z.string().optional(), keyword: z.string().optional(),
stage: z.coerce.number().int().optional(), stage: z.coerce.number().int().optional(),
status: z.coerce.number().int().optional(), status: z.coerce.number().int().optional(),
ownerUserId: z.string().optional(),
...pagination, ...pagination,
}) })
...@@ -66,17 +86,13 @@ export const projectCodeParam = z.object({ ...@@ -66,17 +86,13 @@ export const projectCodeParam = z.object({
}) })
export const projectCreateBody = z.object({ export const projectCreateBody = z.object({
name: z.string().min(1).max(255), name: z.string().trim().min(1).max(255),
province: z.string().max(120).optional(), province: z.string().trim().min(1).max(120),
city: z.string().max(120).optional(), city: z.string().trim().min(1).max(120),
schoolName: z.string().max(255).optional(), schoolName: z.string().trim().min(1).max(255),
departmentName: z.string().max(255).optional(), departmentName: z.string().max(255).optional(),
productId: z.coerce.number().int().optional(), productId: z.coerce.number().int().positive(),
productName: z.string().max(255).optional(), productName: z.string().max(255).optional(),
ownerUserId: z.string().max(64).optional(),
ownerName: z.string().max(120).optional(),
ownerMobile: z.string().max(64).optional(),
ownerDingUserId: z.string().max(128).optional(),
contactName: z.string().max(120).optional(), contactName: z.string().max(120).optional(),
contactTitle: z.string().max(120).optional(), contactTitle: z.string().max(120).optional(),
contactPhone: z.string().max(64).optional(), contactPhone: z.string().max(64).optional(),
...@@ -85,75 +101,70 @@ export const projectCreateBody = z.object({ ...@@ -85,75 +101,70 @@ export const projectCreateBody = z.object({
export const projectUpdateBody = projectCreateBody.partial() export const projectUpdateBody = projectCreateBody.partial()
export const solutionUpsertBody = z.object({
solution: z.string().nullish(),
})
export const moveStageBody = z.object({ export const moveStageBody = z.object({
toStage: z.coerce.number().int(), toStage: z.coerce.number().int(),
description: z.string().optional(), description: z.string().optional(),
}) })
export const rollbackStageBody = z.object({
reason: z.string().min(1).max(500),
})
export const projectTeamBody = z.object({
assignments: z.array(z.object({
ssoId: z.string().min(1).max(64),
userName: z.string().max(120).optional(),
mobile: z.string().max(64).optional(),
role: z.enum([
'project_manager', 'solution_owner', 'initiation_owner', 'procurement_owner',
'contract_owner', 'delivery_owner', 'acceptance_owner', 'viewer',
]),
})),
})
// ── phase upserts ──────────────────────────────────────────────────── // ── phase upserts ────────────────────────────────────────────────────
export const initiationUpsertBody = z.object({ export const initiationUpsertBody = z.object({
applicationFileUrl: z.string().max(500).optional(), applicationFileUrl: fileListValue,
argumentFileUrl: z.string().max(500).optional(), argumentFileUrl: fileListValue,
projectAmount: z.coerce.number().optional(), projectAmount: optionalNumber,
fundSource: z.string().max(255).optional(), fundSource: z.string().max(255).nullish(),
executionPlan: z.string().optional(), executionPlan: z.string().nullish(),
expectedTenderTime: z.string().max(120).optional(), expectedTenderTime: z.string().max(120).nullish(),
}) })
export const procurementUpsertBody = z.object({ export const procurementUpsertBody = z.object({
tenderOwner: z.string().max(120).optional(), tenderOwner: z.string().max(120).nullish(),
businessParams: z.string().optional(), businessParams: z.string().nullish(),
technicalParams: z.string().optional(), technicalParams: z.string().nullish(),
intentionPublicity: z.string().max(255).optional(), intentionPublicity: z.string().max(255).nullish(),
mainBidOwner: z.string().max(120).optional(), mainBidOwner: z.string().max(120).nullish(),
companionBidders: z.string().optional(), companionBidders: z.string().nullish(),
formalBidStatus: z.string().max(120).optional(), formalBidStatus: z.string().max(120).nullish(),
winningNoticeFileUrl: z.string().max(500).optional(), winningNoticeFileUrl: fileListValue,
bidArchiveFileUrl: z.string().max(500).optional(), bidArchiveFileUrl: fileListValue,
}) })
export const contractUpsertBody = z.object({ export const contractUpsertBody = z.object({
contractNo: z.string().max(128).optional(), contractNo: z.string().max(128).nullish(),
contractName: z.string().max(255).optional(), contractName: z.string().max(255).nullish(),
amount: z.coerce.number().optional(), amount: optionalNumber,
drafter: z.string().max(120).optional(), drafter: z.string().max(120).nullish(),
archiveFileUrl: z.string().max(500).optional(), archiveFileUrl: fileListValue,
}) })
export const deliveryUpsertBody = z.object({ export const deliveryUpsertBody = z.object({
deliveryAddress: z.string().max(255).optional(), deliveryAddress: z.string().max(255).nullish(),
deliveryContact: z.string().max(120).optional(), deliveryContact: z.string().max(120).nullish(),
deliveryContactPhone: z.string().max(64).optional(), deliveryContactPhone: z.string().max(64).nullish(),
deliveryNote: z.string().optional(), deliveryNote: z.string().nullish(),
completedAt: z.string().optional(),
}) })
export const acceptanceUpsertBody = z.object({ export const acceptanceUpsertBody = z.object({
acceptanceReportUrl: z.string().max(500).optional(), acceptanceReportUrl: fileListValue,
acceptanceNote: z.string().optional(), acceptanceNote: z.string().nullish(),
})
// ── approvals ────────────────────────────────────────────────────────
export const projectCodeWithApprovalTypeParam = z.object({
projectCode: z.string().min(1).max(64),
approvalType: z.string().min(1),
})
export const approvalInstanceIdParam = z.object({
id: z.coerce.number().int().positive(),
})
// ── dingtalk user bindings ───────────────────────────────────────────
export const systemUserIdParam = z.object({
systemUserId: z.string().min(1).max(64),
})
export const userBindingUpsertBody = z.object({
systemUserId: z.string().min(1).max(64),
dingUserId: z.string().min(1).max(128),
name: z.string().max(120).optional(),
mobile: z.string().max(64).optional(),
}) })
import { and, count, desc, eq, like, or } from 'drizzle-orm' import { and, count, desc, eq, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js' import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.js' import { db } from '../../../db/index.js'
import { cases } from '../cases/schema.js'
import { projects } from '../projects/schema.js' import { projects } from '../projects/schema.js'
import { products, PRODUCT_STATUSES } from './schema.js' import { products, PRODUCT_STATUSES } from './schema.js'
const normalizeProductPayload = (payload = {}) => ({
name: typeof payload.name === 'string' ? payload.name.trim() : '',
status: Number(payload.status ?? PRODUCT_STATUSES.ENABLED),
description: typeof payload.description === 'string' ? payload.description.trim() : '',
})
const resolveOperator = (currentUser = {}) => ({ const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null, operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null, operatorName: currentUser.userName || null,
...@@ -58,7 +53,7 @@ export const getProductById = async (id) => { ...@@ -58,7 +53,7 @@ export const getProductById = async (id) => {
} }
export const createProduct = async (payload = {}, currentUser = {}) => { export const createProduct = async (payload = {}, currentUser = {}) => {
const values = normalizeProductPayload(payload) const values = payload
validateProduct(values) validateProduct(values)
const [result] = await db.insert(products).values({ const [result] = await db.insert(products).values({
...values, ...values,
...@@ -72,7 +67,7 @@ export const updateProduct = async (id, payload = {}, currentUser = {}) => { ...@@ -72,7 +67,7 @@ export const updateProduct = async (id, payload = {}, currentUser = {}) => {
const product = await getProductById(id) const product = await getProductById(id)
if (!product) return null if (!product) return null
const values = normalizeProductPayload(payload) const values = payload
validateProduct(values) validateProduct(values)
await db await db
.update(products) .update(products)
...@@ -107,9 +102,12 @@ export const updateProductStatus = async (id, payload = {}, currentUser = {}) => ...@@ -107,9 +102,12 @@ export const updateProductStatus = async (id, payload = {}, currentUser = {}) =>
export const deleteProduct = async (id) => { export const deleteProduct = async (id) => {
const product = await getProductById(id) const product = await getProductById(id)
if (!product) return null if (!product) return null
const usedRows = await db.select({ total: count() }).from(projects).where(eq(projects.productId, Number(id))) const [projectRows, caseRows] = await Promise.all([
if (usedRows[0]?.total) { db.select({ total: count() }).from(projects).where(eq(projects.productId, Number(id))),
throw httpError(400, '产品已被项目使用,不能删除') db.select({ total: count() }).from(cases).where(eq(cases.productId, Number(id))),
])
if (projectRows[0]?.total || caseRows[0]?.total) {
throw httpError(400, '产品已被项目或案例使用,不能删除')
} }
await db.delete(products).where(eq(products.id, Number(id))) await db.delete(products).where(eq(products.id, Number(id)))
...@@ -121,7 +119,7 @@ const validateProduct = (values) => { ...@@ -121,7 +119,7 @@ const validateProduct = (values) => {
if (!values.name) { if (!values.name) {
throw httpError(400, '产品名称不能为空') throw httpError(400, '产品名称不能为空')
} }
if (![PRODUCT_STATUSES.DISABLED, PRODUCT_STATUSES.ENABLED].includes(values.status)) { if (values.status !== undefined && ![PRODUCT_STATUSES.DISABLED, PRODUCT_STATUSES.ENABLED].includes(values.status)) {
throw httpError(400, '产品状态不正确') throw httpError(400, '产品状态不正确')
} }
} }
import { getProjectByCode, userHasAnyProjectRole } from './service.js'
export const requireProjectRole = (...roles) => async (req, _res, next) => {
try {
if (req.user?.roles?.includes('admin')) return next()
const project = await getProjectByCode(req.params.projectCode)
if (!project) {
const error = new Error('项目不存在')
error.statusCode = 404
throw error
}
const allowed = await userHasAnyProjectRole(project.id, req.user.ssoId, roles)
if (!allowed) {
const error = new Error('您不是当前项目的授权负责人')
error.statusCode = 403
throw error
}
req.project = project
next()
} catch (error) {
next(error)
}
}
export const requireProjectAccess = async (req, _res, next) => {
try {
if (req.user?.roles?.includes('admin')) return next()
const project = await getProjectByCode(req.params.projectCode)
if (!project) {
const error = new Error('项目不存在')
error.statusCode = 404
throw error
}
const allowed = await userHasAnyProjectRole(project.id, req.user?.ssoId)
if (!allowed) {
const error = new Error('您不是该项目成员')
error.statusCode = 403
throw error
}
req.project = project
next()
} catch (error) {
next(error)
}
}
import { error, success } from '../../../lib/response.js'
import * as projectRolesService from './service.js'
export const list = async (req, res) => {
const project = await projectRolesService.getProjectByCode(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
success(res, { assignments: await projectRolesService.listAssignments(project.id) })
}
export const replace = async (req, res) => {
const project = await projectRolesService.getProjectByCode(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
const assignments = await projectRolesService.replaceAssignments(project.id, req.body.assignments, req.user)
success(res, { assignments })
}
import { bigint, datetime, index, mysqlTable, tinyint, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { sql } from 'drizzle-orm'
import { timestamps } from '../db/columns.js'
import { projects } from '../projects/schema.js'
export const PROJECT_ROLES = [
'project_manager',
'solution_owner',
'initiation_owner',
'procurement_owner',
'contract_owner',
'delivery_owner',
'acceptance_owner',
'viewer',
]
export const projectRoleAssignments = mysqlTable('project_role_assignments', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
ssoId: varchar('sso_id', { length: 64 }).notNull(),
userName: varchar('user_name', { length: 120 }),
mobile: varchar('mobile', { length: 64 }),
role: varchar('role', { length: 32 }).notNull(),
status: tinyint('status', { unsigned: true }).notNull().default(1),
assignedBySsoId: varchar('assigned_by_sso_id', { length: 64 }),
assignedAt: datetime('assigned_at').notNull().default(sql`CURRENT_TIMESTAMP`),
...timestamps,
}, (table) => ({
projectIdx: index('idx_project_roles_project_id').on(table.projectId),
userIdx: index('idx_project_roles_sso_id').on(table.ssoId),
projectRoleIdx: index('idx_project_roles_project_role').on(table.projectId, table.role),
assignmentIdx: uniqueIndex('uk_project_roles_assignment').on(table.projectId, table.ssoId, table.role),
}))
import { and, eq, inArray } from 'drizzle-orm'
import { db } from '../../../db/index.js'
import { httpError } from '../../../lib/http-error.js'
import { projectTimelines, projects } from '../projects/schema.js'
import { PROJECT_ROLES, projectRoleAssignments } from './schema.js'
const ACTIVE = 1
export const getProjectByCode = async (projectCode) => {
const rows = await db.select().from(projects).where(eq(projects.projectCode, projectCode)).limit(1)
return rows[0] || null
}
export const listAssignments = async (projectId) => db
.select()
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.projectId, projectId), eq(projectRoleAssignments.status, ACTIVE)))
export const getUserProjectRoles = async (projectId, ssoId) => {
if (!ssoId) return []
const rows = await db
.select({ role: projectRoleAssignments.role })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.projectId, projectId),
eq(projectRoleAssignments.ssoId, ssoId),
eq(projectRoleAssignments.status, ACTIVE),
))
return rows.map((row) => row.role)
}
export const replaceAssignments = async (projectId, assignments = [], currentUser = {}) => {
const normalized = assignments.map((item) => ({
ssoId: String(item.ssoId),
userName: item.userName?.trim() || null,
mobile: item.mobile?.trim() || null,
role: item.role,
}))
for (const item of normalized) {
if (!PROJECT_ROLES.includes(item.role)) throw httpError(400, `项目角色不正确:${item.role}`)
}
if (!normalized.some((item) => item.role === 'project_manager')) {
throw httpError(400, '项目负责人至少保留一人')
}
await db.transaction(async (tx) => {
const projectRows = await tx.select({
projectCode: projects.projectCode,
stage: projects.stage,
status: projects.status,
}).from(projects).where(eq(projects.id, projectId)).limit(1)
const project = projectRows[0]
if (!project) throw httpError(404, '项目不存在')
const existing = await tx.select({
ssoId: projectRoleAssignments.ssoId,
userName: projectRoleAssignments.userName,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
status: projectRoleAssignments.status,
}).from(projectRoleAssignments).where(eq(projectRoleAssignments.projectId, projectId))
const signature = (items) => items
.map((item) => [item.ssoId, item.userName || '', item.mobile || '', item.role, item.status ?? 1].join('|'))
.sort()
.join(';')
const changed = signature(existing) !== signature(normalized)
await tx.delete(projectRoleAssignments).where(eq(projectRoleAssignments.projectId, projectId))
if (normalized.length) {
await tx.insert(projectRoleAssignments).values(normalized.map((item) => ({
projectId,
...item,
assignedBySsoId: currentUser.ssoId,
})))
}
if (changed) {
await tx.insert(projectTimelines).values({
projectId,
projectCode: project.projectCode,
title: '项目团队已更新',
description: '项目角色分工已更新',
stage: project.stage,
status: project.status,
operatorUserId: currentUser.ssoId || null,
operatorName: currentUser.name || null,
})
}
})
return listAssignments(projectId)
}
export const userHasAnyProjectRole = async (projectId, ssoId, allowedRoles = []) => {
const conditions = [
eq(projectRoleAssignments.projectId, projectId),
eq(projectRoleAssignments.ssoId, ssoId),
eq(projectRoleAssignments.status, ACTIVE),
]
if (allowedRoles.length) conditions.push(inArray(projectRoleAssignments.role, allowedRoles))
const rows = await db.select({ id: projectRoleAssignments.id }).from(projectRoleAssignments).where(and(...conditions)).limit(1)
return Boolean(rows[0])
}
import * as projectsService from './service.js' import * as projectsService from './service.js'
import { success, error } from '../../../lib/response.js' import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js' import { getCurrentUser } from '../utils/current-user.js'
import { listAssignments } from '../project-roles/service.js'
export const create = async (req, res) => { export const create = async (req, res) => {
const project = await projectsService.createProject(req.body, getCurrentUser(req)) const project = await projectsService.createProject(req.body, getCurrentUser(req))
...@@ -8,15 +9,20 @@ export const create = async (req, res) => { ...@@ -8,15 +9,20 @@ export const create = async (req, res) => {
} }
export const list = async (req, res) => { export const list = async (req, res) => {
const { keyword, stage, status, ownerUserId, page, limit } = req.query const { keyword, stage, status, page, limit } = req.query
const result = await projectsService.listProjects({ keyword, stage, status, ownerUserId }, { page, limit }) const result = await projectsService.listProjects(
{ keyword, stage, status },
{ page, limit },
req.user ? { userId: req.user.ssoId, roles: req.user.roles || [] } : {},
)
success(res, result) success(res, result)
} }
export const detail = async (req, res) => { export const detail = async (req, res) => {
const project = await projectsService.getProjectDetail(req.params.projectCode) const project = await projectsService.getProjectDetail(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404) if (!project) return error(res, '项目不存在', 404)
success(res, project) const assignments = await listAssignments(project.id)
success(res, { ...project, team: assignments, access: await projectsService.getProjectAccess(project, req.user) })
} }
export const update = async (req, res) => { export const update = async (req, res) => {
...@@ -36,6 +42,7 @@ export const upsertProcurement = upsertPhase(projectsService.upsertProcurement) ...@@ -36,6 +42,7 @@ export const upsertProcurement = upsertPhase(projectsService.upsertProcurement)
export const upsertContract = upsertPhase(projectsService.upsertContract) export const upsertContract = upsertPhase(projectsService.upsertContract)
export const upsertDelivery = upsertPhase(projectsService.upsertDelivery) export const upsertDelivery = upsertPhase(projectsService.upsertDelivery)
export const upsertAcceptance = upsertPhase(projectsService.upsertAcceptance) export const upsertAcceptance = upsertPhase(projectsService.upsertAcceptance)
export const updateSolution = upsertPhase(projectsService.updateSolution)
export const moveStage = async (req, res) => { export const moveStage = async (req, res) => {
const project = await projectsService.moveProjectStage(req.params.projectCode, req.body, getCurrentUser(req)) const project = await projectsService.moveProjectStage(req.params.projectCode, req.body, getCurrentUser(req))
...@@ -43,6 +50,12 @@ export const moveStage = async (req, res) => { ...@@ -43,6 +50,12 @@ export const moveStage = async (req, res) => {
success(res, project) success(res, project)
} }
export const rollbackStage = async (req, res) => {
const project = await projectsService.rollbackProjectStage(req.params.projectCode, req.body.reason, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
export const remove = async (req, res) => { export const remove = async (req, res) => {
const project = await projectsService.deleteProject(req.params.projectCode) const project = await projectsService.deleteProject(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404) if (!project) return error(res, '项目不存在', 404)
......
...@@ -23,7 +23,6 @@ export const PROJECT_STAGES = { ...@@ -23,7 +23,6 @@ export const PROJECT_STAGES = {
export const PROJECT_STATUSES = { export const PROJECT_STATUSES = {
ACTIVE: 0, ACTIVE: 0,
PENDING: 10,
ARCHIVED: 20, ARCHIVED: 20,
} }
...@@ -39,10 +38,6 @@ export const projects = mysqlTable( ...@@ -39,10 +38,6 @@ export const projects = mysqlTable(
departmentName: varchar('department_name', { length: 255 }), departmentName: varchar('department_name', { length: 255 }),
productId: bigint('product_id', { mode: 'number', unsigned: true }), productId: bigint('product_id', { mode: 'number', unsigned: true }),
productName: varchar('product_name', { length: 255 }), productName: varchar('product_name', { length: 255 }),
ownerUserId: varchar('owner_user_id', { length: 64 }),
ownerName: varchar('owner_name', { length: 120 }),
ownerMobile: varchar('owner_mobile', { length: 64 }),
ownerDingUserId: varchar('owner_ding_user_id', { length: 128 }),
contactName: varchar('contact_name', { length: 120 }), contactName: varchar('contact_name', { length: 120 }),
contactTitle: varchar('contact_title', { length: 120 }), contactTitle: varchar('contact_title', { length: 120 }),
contactPhone: varchar('contact_phone', { length: 64 }), contactPhone: varchar('contact_phone', { length: 64 }),
...@@ -58,8 +53,6 @@ export const projects = mysqlTable( ...@@ -58,8 +53,6 @@ export const projects = mysqlTable(
projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.projectCode), projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.projectCode),
stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status), stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status),
productIdIdx: index('idx_projects_product_id').on(table.productId), productIdIdx: index('idx_projects_product_id').on(table.productId),
ownerUserIdIdx: index('idx_projects_owner_user_id').on(table.ownerUserId),
ownerMobileIdx: index('idx_projects_owner_mobile').on(table.ownerMobile),
operatorUserIdIdx: index('idx_projects_operator_user_id').on(table.operatorUserId), operatorUserIdIdx: index('idx_projects_operator_user_id').on(table.operatorUserId),
}) })
) )
...@@ -71,8 +64,8 @@ export const initiations = mysqlTable( ...@@ -71,8 +64,8 @@ export const initiations = mysqlTable(
projectId: bigint('project_id', { mode: 'number', unsigned: true }) projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
applicationFileUrl: varchar('application_file_url', { length: 500 }), applicationFileUrl: text('application_file_url'),
argumentFileUrl: varchar('argument_file_url', { length: 500 }), argumentFileUrl: text('argument_file_url'),
projectAmount: decimal('project_amount', { precision: 14, scale: 2 }), projectAmount: decimal('project_amount', { precision: 14, scale: 2 }),
fundSource: varchar('fund_source', { length: 255 }), fundSource: varchar('fund_source', { length: 255 }),
executionPlan: text('execution_plan'), executionPlan: text('execution_plan'),
...@@ -98,8 +91,8 @@ export const procurements = mysqlTable( ...@@ -98,8 +91,8 @@ export const procurements = mysqlTable(
mainBidOwner: varchar('main_bid_owner', { length: 120 }), mainBidOwner: varchar('main_bid_owner', { length: 120 }),
companionBidders: text('companion_bidders'), companionBidders: text('companion_bidders'),
formalBidStatus: varchar('formal_bid_status', { length: 120 }), formalBidStatus: varchar('formal_bid_status', { length: 120 }),
winningNoticeFileUrl: varchar('winning_notice_file_url', { length: 500 }), winningNoticeFileUrl: text('winning_notice_file_url'),
bidArchiveFileUrl: varchar('bid_archive_file_url', { length: 500 }), bidArchiveFileUrl: text('bid_archive_file_url'),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
...@@ -118,7 +111,7 @@ export const contracts = mysqlTable( ...@@ -118,7 +111,7 @@ export const contracts = mysqlTable(
contractName: varchar('contract_name', { length: 255 }), contractName: varchar('contract_name', { length: 255 }),
amount: decimal('amount', { precision: 14, scale: 2 }), amount: decimal('amount', { precision: 14, scale: 2 }),
drafter: varchar('drafter', { length: 120 }), drafter: varchar('drafter', { length: 120 }),
archiveFileUrl: varchar('archive_file_url', { length: 500 }), archiveFileUrl: text('archive_file_url'),
...timestamps, ...timestamps,
}, },
(table) => ({ (table) => ({
...@@ -146,7 +139,7 @@ export const acceptances = mysqlTable('project_acceptances', { ...@@ -146,7 +139,7 @@ export const acceptances = mysqlTable('project_acceptances', {
projectId: bigint('project_id', { mode: 'number', unsigned: true }) projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull() .notNull()
.references(() => projects.id, { onDelete: 'cascade' }), .references(() => projects.id, { onDelete: 'cascade' }),
acceptanceReportUrl: varchar('acceptance_report_url', { length: 500 }), acceptanceReportUrl: text('acceptance_report_url'),
acceptanceNote: text('acceptance_note'), acceptanceNote: text('acceptance_note'),
...timestamps, ...timestamps,
}, (table) => ({ }, (table) => ({
...@@ -165,6 +158,7 @@ export const projectTimelines = mysqlTable( ...@@ -165,6 +158,7 @@ export const projectTimelines = mysqlTable(
description: text('description'), description: text('description'),
stage: int('stage').notNull(), stage: int('stage').notNull(),
status: int('status').notNull(), status: int('status').notNull(),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }), operatorName: varchar('operator_name', { length: 120 }),
...timestamps, ...timestamps,
}, },
......
import { and, count, desc, eq, like, or } from 'drizzle-orm' import { and, count, desc, eq, inArray, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js' import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.js' import { db } from '../../../db/index.js'
import { import {
...@@ -13,14 +13,8 @@ import { ...@@ -13,14 +13,8 @@ import {
projectTimelines, projectTimelines,
} from './schema.js' } from './schema.js'
import { products } from '../products/schema.js' import { products } from '../products/schema.js'
import { approvalInstances } from '../approvals/schema.js'
import { resolveBindingByMobile } from '../dingtalk/user-bindings.service.js'
import {
APPROVAL_STATUS_CODES,
APPROVAL_TITLE_BY_KEY,
APPROVAL_TYPE_BY_KEY,
} from '../approvals/constants.js'
import { isManualTransitionAllowed, isProjectDeleteAllowed } from './workflow.js' import { isManualTransitionAllowed, isProjectDeleteAllowed } from './workflow.js'
import { projectRoleAssignments } from '../project-roles/schema.js'
const STAGE_LABELS = { const STAGE_LABELS = {
[PROJECT_STAGES.SOLUTION]: '方案阶段', [PROJECT_STAGES.SOLUTION]: '方案阶段',
...@@ -34,10 +28,34 @@ const STAGE_LABELS = { ...@@ -34,10 +28,34 @@ const STAGE_LABELS = {
const STATUS_LABELS = { const STATUS_LABELS = {
[PROJECT_STATUSES.ACTIVE]: '进行中', [PROJECT_STATUSES.ACTIVE]: '进行中',
[PROJECT_STATUSES.PENDING]: '审批中',
[PROJECT_STATUSES.ARCHIVED]: '已归档', [PROJECT_STATUSES.ARCHIVED]: '已归档',
} }
const STAGE_OWNER_ROLES = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const PROJECT_OWNER_ROLES = ['project_manager', ...Object.values(STAGE_OWNER_ROLES)]
const withProjectOwnerFields = (project, assignments = []) => {
const projectManagers = assignments.filter((item) => item.role === 'project_manager')
const stageOwners = assignments.filter((item) => item.role === STAGE_OWNER_ROLES[project.stage])
return {
...project,
projectManagerSsoIds: projectManagers.map((item) => item.ssoId),
projectManagerName: projectManagers.map((item) => item.userName || item.ssoId).join('、') || null,
currentStageOwnerName: stageOwners.map((item) => item.userName || item.ssoId).join('、') || null,
currentStageOwnerMobile: stageOwners.map((item) => item.mobile).filter(Boolean).join('、') || null,
currentStageOwnerSsoIds: stageOwners.map((item) => item.ssoId),
}
}
const buildProjectCode = () => { const buildProjectCode = () => {
const date = new Date() const date = new Date()
const ymd = date.toISOString().slice(0, 10).replaceAll('-', '') const ymd = date.toISOString().slice(0, 10).replaceAll('-', '')
...@@ -59,6 +77,67 @@ const pickDefined = (payload, fields) => ...@@ -59,6 +77,67 @@ const pickDefined = (payload, fields) =>
return values return values
}, {}) }, {})
const FILE_URL_FIELDS = new Set([
'applicationFileUrl',
'argumentFileUrl',
'winningNoticeFileUrl',
'bidArchiveFileUrl',
'archiveFileUrl',
'acceptanceReportUrl',
])
const normalizeFileUrls = (value) => {
if (value === null || value === '') return ''
let files
try {
files = JSON.parse(value)
} catch {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
if (!Array.isArray(files) || files.some((file) => (
!file
|| typeof file.url !== 'string' || !file.url
|| typeof file.size !== 'string'
|| typeof file.name !== 'string' || !file.name
|| typeof file.type !== 'string'
))) {
throw httpError(400, '附件字段必须是 JSON 文件数组字符串')
}
return JSON.stringify(files.map(({ url, size, name, type }) => ({ url, size, name, type })))
}
const hasFiles = (value) => {
if (!value) return false
try {
const files = JSON.parse(value)
return Array.isArray(files) && files.length > 0
} catch {
return false
}
}
const normalizeFileUrlFields = (values) => {
for (const field of Object.keys(values)) {
if (FILE_URL_FIELDS.has(field)) values[field] = normalizeFileUrls(values[field])
}
return values
}
const assertProjectStageEditable = (project, stage) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改阶段资料')
if (project.stage !== stage) throw httpError(409, '只能修改当前阶段资料,请先退回到该阶段')
}
const assertProjectBaseEditable = (project) => {
if (project.status !== PROJECT_STATUSES.ACTIVE) throw httpError(400, '已归档项目不可修改基础信息')
if (![PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage)) {
throw httpError(409, '进入后续阶段后不可修改项目基础信息')
}
}
const resolveOperator = (currentUser = {}) => ({ const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null, operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null, operatorName: currentUser.userName || null,
...@@ -98,7 +177,7 @@ const resolveProductFields = async (db, payload = {}) => { ...@@ -98,7 +177,7 @@ const resolveProductFields = async (db, payload = {}) => {
} }
} }
export const appendTimeline = async (db, project, { title, description, stage, status, operatorName }) => { export const appendTimeline = async (db, project, { title, description, stage, status, operatorUserId, operatorName }) => {
await db.insert(projectTimelines).values({ await db.insert(projectTimelines).values({
projectId: project.id, projectId: project.id,
projectCode: project.projectCode, projectCode: project.projectCode,
...@@ -106,6 +185,7 @@ export const appendTimeline = async (db, project, { title, description, stage, s ...@@ -106,6 +185,7 @@ export const appendTimeline = async (db, project, { title, description, stage, s
description: description || null, description: description || null,
stage: stage ?? project.stage, stage: stage ?? project.stage,
status: status ?? project.status, status: status ?? project.status,
operatorUserId: operatorUserId || null,
operatorName: operatorName || null, operatorName: operatorName || null,
}) })
} }
...@@ -116,32 +196,13 @@ const appendStageTimeline = async (db, project, stage, status, description, opti ...@@ -116,32 +196,13 @@ const appendStageTimeline = async (db, project, stage, status, description, opti
description, description,
stage, stage,
status, status,
operatorUserId: options.operatorUserId,
operatorName: options.operatorName, operatorName: options.operatorName,
}) })
} }
const mapApproval = (approval) => ({
id: approval.id,
type: APPROVAL_TYPE_BY_KEY[approval.approvalKey] || approval.approvalKey,
title: APPROVAL_TITLE_BY_KEY[approval.approvalKey] || approval.approvalKey,
status: APPROVAL_STATUS_CODES[approval.status] || PROJECT_STATUSES.PENDING,
createdAt: approval.createdAt,
finishedAt: ['approved', 'rejected', 'terminated', 'deleted'].includes(approval.status) ? approval.updatedAt : null,
remark: approval.errorMessage || null,
})
export const createProject = async (payload = {}, currentUser = {}) => { export const createProject = async (payload = {}, currentUser = {}) => {
const projectCode = payload.projectCode || buildProjectCode() const projectCode = payload.projectCode || buildProjectCode()
const ownerUserId = payload.ownerUserId || currentUser.userId || null
const ownerName = payload.ownerName || currentUser.userName || null
const ownerMobile = payload.ownerMobile || currentUser.mobile || null
const ownerDingUserId = await resolveOwnerDingUserId({
ownerDingUserId: payload.ownerDingUserId || currentUser.dingUserId,
ownerUserId,
ownerName,
ownerMobile,
})
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const productFields = await resolveProductFields(tx, payload) const productFields = await resolveProductFields(tx, payload)
const [insertResult] = await tx.insert(projects).values({ const [insertResult] = await tx.insert(projects).values({
...@@ -153,22 +214,29 @@ export const createProject = async (payload = {}, currentUser = {}) => { ...@@ -153,22 +214,29 @@ export const createProject = async (payload = {}, currentUser = {}) => {
departmentName: payload.departmentName || null, departmentName: payload.departmentName || null,
productId: productFields.productId || null, productId: productFields.productId || null,
productName: productFields.productName || null, productName: productFields.productName || null,
ownerUserId,
ownerName,
ownerMobile,
ownerDingUserId,
contactName: payload.contactName || null, contactName: payload.contactName || null,
contactTitle: payload.contactTitle || null, contactTitle: payload.contactTitle || null,
contactPhone: payload.contactPhone || null, contactPhone: payload.contactPhone || null,
solution: payload.solution || null, solution: payload.solution || null,
stage: toNumberOrUndefined(payload.stage) ?? PROJECT_STAGES.SOLUTION, stage: toNumberOrUndefined(payload.stage) ?? PROJECT_STAGES.SOLUTION,
status: toNumberOrUndefined(payload.status) ?? PROJECT_STATUSES.ACTIVE, status: PROJECT_STATUSES.ACTIVE,
description: payload.description || null, description: payload.description || null,
...resolveOperator(currentUser), ...resolveOperator(currentUser),
}) })
const project = await getProjectByCodeWithDb(tx, projectCode) const project = await getProjectByCodeWithDb(tx, projectCode)
const projectId = insertResult.insertId || project.id const projectId = insertResult.insertId || project.id
if (currentUser.userId) {
await tx.insert(projectRoleAssignments).values({
projectId,
ssoId: currentUser.userId,
userName: currentUser.userName || null,
mobile: currentUser.mobile || null,
role: 'project_manager',
assignedBySsoId: currentUser.userId,
})
}
await tx.insert(initiations).values({ projectId }) await tx.insert(initiations).values({ projectId })
await tx.insert(procurements).values({ projectId }) await tx.insert(procurements).values({ projectId })
await tx.insert(contracts).values({ projectId }) await tx.insert(contracts).values({ projectId })
...@@ -179,7 +247,8 @@ export const createProject = async (payload = {}, currentUser = {}) => { ...@@ -179,7 +247,8 @@ export const createProject = async (payload = {}, currentUser = {}) => {
description: '项目已创建,进入方案阶段', description: '项目已创建,进入方案阶段',
stage: PROJECT_STAGES.SOLUTION, stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE, status: PROJECT_STATUSES.ACTIVE,
operatorName: currentUser.userName || ownerName || null, operatorName: currentUser.userName || null,
operatorUserId: currentUser.userId || null,
}) })
}) })
...@@ -191,12 +260,23 @@ const buildProjectFilters = (filters = {}) => { ...@@ -191,12 +260,23 @@ const buildProjectFilters = (filters = {}) => {
if (filters.keyword) { if (filters.keyword) {
const keyword = `%${filters.keyword}%` const keyword = `%${filters.keyword}%`
const matchedOwnerProjects = db
.select({ projectId: projectRoleAssignments.projectId })
.from(projectRoleAssignments)
.where(and(
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
or(
like(projectRoleAssignments.userName, keyword),
like(projectRoleAssignments.mobile, keyword),
),
))
conditions.push(or( conditions.push(or(
like(projects.projectCode, keyword), like(projects.projectCode, keyword),
like(projects.name, keyword), like(projects.name, keyword),
like(projects.schoolName, keyword), like(projects.schoolName, keyword),
like(projects.ownerName, keyword),
like(projects.operatorName, keyword), like(projects.operatorName, keyword),
inArray(projects.id, matchedOwnerProjects),
)) ))
} }
...@@ -204,16 +284,23 @@ const buildProjectFilters = (filters = {}) => { ...@@ -204,16 +284,23 @@ const buildProjectFilters = (filters = {}) => {
const status = toNumberOrUndefined(filters.status) const status = toNumberOrUndefined(filters.status)
if (stage !== undefined) conditions.push(eq(projects.stage, stage)) if (stage !== undefined) conditions.push(eq(projects.stage, stage))
if (status !== undefined) conditions.push(eq(projects.status, status)) if (status !== undefined) conditions.push(eq(projects.status, status))
if (filters.ownerUserId) conditions.push(eq(projects.ownerUserId, filters.ownerUserId)) if (filters.projectIds) conditions.push(inArray(projects.id, filters.projectIds))
return conditions.length ? and(...conditions) : undefined return conditions.length ? and(...conditions) : undefined
} }
export const listProjects = async (filters = {}, options = {}) => { export const listProjects = async (filters = {}, options = {}, currentUser = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1) const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100) const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit const offset = (page - 1) * limit
const where = buildProjectFilters(filters) let projectIds
if (!currentUser.roles?.includes('admin')) {
const rows = await db.select({ projectId: projectRoleAssignments.projectId })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.ssoId, currentUser.userId), eq(projectRoleAssignments.status, 1)))
projectIds = rows.map((row) => row.projectId)
if (!projectIds.length) return { list: [], pagination: { page, limit, total: 0, pages: 0 } }
}
const where = buildProjectFilters({ ...filters, projectIds })
const [projectRows, totalRows] = await Promise.all([ const [projectRows, totalRows] = await Promise.all([
db db
...@@ -226,8 +313,34 @@ export const listProjects = async (filters = {}, options = {}) => { ...@@ -226,8 +313,34 @@ export const listProjects = async (filters = {}, options = {}) => {
.offset(offset), .offset(offset),
db.select({ total: count() }).from(projects).where(where), db.select({ total: count() }).from(projects).where(where),
]) ])
const projectIdsInPage = projectRows.map((row) => row.project.id)
const assignmentRows = projectIdsInPage.length
? await db.select({
projectId: projectRoleAssignments.projectId,
ssoId: projectRoleAssignments.ssoId,
userName: projectRoleAssignments.userName,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
inArray(projectRoleAssignments.projectId, projectIdsInPage),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
: []
const assignmentsByProjectId = new Map()
for (const assignment of assignmentRows) {
const current = assignmentsByProjectId.get(assignment.projectId) || []
current.push(assignment)
assignmentsByProjectId.set(assignment.projectId, current)
}
const list = projectRows.map((row) => ({ const list = projectRows.map((row) => ({
...row.project, ...withProjectOwnerFields(row.project, assignmentsByProjectId.get(row.project.id)),
canEditProject: currentUser.roles?.includes('admin')
|| (
row.project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(row.project.stage)
&& assignmentsByProjectId.get(row.project.id)?.some((item) => item.role === 'project_manager' && item.ssoId === currentUser.userId)
),
productName: row.productName || row.project.productName, productName: row.productName || row.project.productName,
})) }))
const total = totalRows[0]?.total || 0 const total = totalRows[0]?.total || 0
...@@ -255,7 +368,6 @@ export const getProjectDetail = async (projectCode) => { ...@@ -255,7 +368,6 @@ export const getProjectDetail = async (projectCode) => {
contractRows, contractRows,
deliveryRows, deliveryRows,
acceptanceRows, acceptanceRows,
approvalRows,
timelineRows, timelineRows,
] = await Promise.all([ ] = await Promise.all([
db.select().from(initiations).where(eq(initiations.projectId, project.id)).limit(1), db.select().from(initiations).where(eq(initiations.projectId, project.id)).limit(1),
...@@ -263,22 +375,58 @@ export const getProjectDetail = async (projectCode) => { ...@@ -263,22 +375,58 @@ export const getProjectDetail = async (projectCode) => {
db.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1), db.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1),
db.select().from(deliveries).where(eq(deliveries.projectId, project.id)).limit(1), db.select().from(deliveries).where(eq(deliveries.projectId, project.id)).limit(1),
db.select().from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1), db.select().from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1),
db.select().from(approvalInstances).where(eq(approvalInstances.projectId, project.id)).orderBy(desc(approvalInstances.createdAt)),
db.select().from(projectTimelines).where(eq(projectTimelines.projectId, project.id)).orderBy(desc(projectTimelines.createdAt)), db.select().from(projectTimelines).where(eq(projectTimelines.projectId, project.id)).orderBy(desc(projectTimelines.createdAt)),
]) ])
const ownerRows = await db.select({
ssoId: projectRoleAssignments.ssoId,
userName: projectRoleAssignments.userName,
mobile: projectRoleAssignments.mobile,
role: projectRoleAssignments.role,
}).from(projectRoleAssignments).where(and(
eq(projectRoleAssignments.projectId, project.id),
eq(projectRoleAssignments.status, 1),
inArray(projectRoleAssignments.role, PROJECT_OWNER_ROLES),
))
return { return {
...project, ...withProjectOwnerFields(project, ownerRows),
initiation: initiationRows[0] || {}, initiation: initiationRows[0] || {},
procurement: procurementRows[0] || {}, procurement: procurementRows[0] || {},
contract: contractRows[0] || {}, contract: contractRows[0] || {},
delivery: deliveryRows[0] || {}, delivery: deliveryRows[0] || {},
acceptance: acceptanceRows[0] || {}, acceptance: acceptanceRows[0] || {},
approvals: approvalRows.map(mapApproval),
timeline: timelineRows, timeline: timelineRows,
} }
} }
export const getProjectAccess = async (project, user = {}) => {
const activeStages = Object.values(PROJECT_STAGES).filter((stage) => stage !== PROJECT_STAGES.ARCHIVED)
const editableStages = project.status === PROJECT_STATUSES.ACTIVE && activeStages.includes(project.stage)
? [project.stage]
: []
if (user?.roles?.includes('admin')) {
return {
canEditProject: project.status === PROJECT_STATUSES.ACTIVE
&& [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION].includes(project.stage),
canManageTeam: true,
canMoveStage: project.status === PROJECT_STATUSES.ACTIVE,
canRollbackStage: true,
editableStages,
}
}
const roles = await getUserProjectRoles(project.id, user?.ssoId)
const isProjectManager = roles.includes('project_manager')
const isCurrentOwner = roles.includes(STAGE_OWNER_ROLES[project.stage])
return {
canEditProject: isProjectManager && project.status === PROJECT_STATUSES.ACTIVE,
canManageTeam: isProjectManager,
canMoveStage: project.status === PROJECT_STATUSES.ACTIVE && (isProjectManager || isCurrentOwner),
canRollbackStage: isProjectManager,
editableStages: isProjectManager || isCurrentOwner ? editableStages : [],
}
}
export const updateProject = async (projectCode, payload = {}, currentUser = {}) => { export const updateProject = async (projectCode, payload = {}, currentUser = {}) => {
const values = pickDefined(payload, [ const values = pickDefined(payload, [
'name', 'name',
...@@ -286,54 +434,39 @@ export const updateProject = async (projectCode, payload = {}, currentUser = {}) ...@@ -286,54 +434,39 @@ export const updateProject = async (projectCode, payload = {}, currentUser = {})
'city', 'city',
'schoolName', 'schoolName',
'departmentName', 'departmentName',
'ownerUserId',
'ownerName',
'ownerMobile',
'ownerDingUserId',
'contactName', 'contactName',
'contactTitle', 'contactTitle',
'contactPhone', 'contactPhone',
'solution',
'description', 'description',
]) ])
Object.assign(values, await resolveProductFields(db, payload)) Object.assign(values, await resolveProductFields(db, payload))
const stage = toNumberOrUndefined(payload.stage)
const status = toNumberOrUndefined(payload.status)
if (stage !== undefined) values.stage = stage
if (status !== undefined) values.status = status
if (payload.ownerMobile && !payload.ownerDingUserId) {
values.ownerDingUserId = await resolveOwnerDingUserId({
ownerDingUserId: payload.ownerDingUserId,
ownerUserId: payload.ownerUserId,
ownerName: payload.ownerName,
ownerMobile: payload.ownerMobile,
})
}
if (Object.keys(values).length) { if (Object.keys(values).length) {
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode) const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return if (!project) return
assertProjectBaseEditable(project)
await tx.update(projects).set({ ...values, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id)) await tx.update(projects).set({ ...values, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
if ((values.stage !== undefined && values.stage !== project.stage) || (values.status !== undefined && values.status !== project.status)) {
await appendStageTimeline(
tx,
project,
values.stage ?? project.stage,
values.status ?? project.status,
'项目阶段或状态已更新',
{ operatorName: currentUser.userName || null },
)
}
}) })
} }
return getProjectDetail(projectCode) return getProjectDetail(projectCode)
} }
export const updateSolution = async (projectCode, payload = {}, currentUser = {}) => {
const solution = typeof payload.solution === 'string' ? payload.solution : ''
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
assertProjectStageEditable(project, PROJECT_STAGES.SOLUTION)
await tx.update(projects).set({ solution, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
})
return exists ? getProjectDetail(projectCode) : null
}
const getOrCreatePhaseRow = async (tx, table, projectId) => { const getOrCreatePhaseRow = async (tx, table, projectId) => {
const rows = await tx.select().from(table).where(eq(table.projectId, projectId)).limit(1) const rows = await tx.select().from(table).where(eq(table.projectId, projectId)).limit(1)
if (rows[0]) return rows[0] if (rows[0]) return rows[0]
...@@ -342,16 +475,17 @@ const getOrCreatePhaseRow = async (tx, table, projectId) => { ...@@ -342,16 +475,17 @@ const getOrCreatePhaseRow = async (tx, table, projectId) => {
return { id: result.insertId, projectId } return { id: result.insertId, projectId }
} }
const upsertPhase = async (projectCode, table, payload, fields, currentUser = {}) => { const upsertPhase = async (projectCode, table, payload, fields, stage, currentUser = {}) => {
let exists = false let exists = false
await db.transaction(async (tx) => { await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode) const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return if (!project) return
exists = true exists = true
assertProjectStageEditable(project, stage)
const row = await getOrCreatePhaseRow(tx, table, project.id) const row = await getOrCreatePhaseRow(tx, table, project.id)
const values = pickDefined(payload, fields) const values = normalizeFileUrlFields(pickDefined(payload, fields))
if (Object.keys(values).length) { if (Object.keys(values).length) {
await tx.update(table).set(values).where(eq(table.id, row.id)) await tx.update(table).set(values).where(eq(table.id, row.id))
await tx.update(projects).set(resolveOperator(currentUser)).where(eq(projects.id, project.id)) await tx.update(projects).set(resolveOperator(currentUser)).where(eq(projects.id, project.id))
...@@ -369,7 +503,7 @@ export const upsertInitiation = (projectCode, payload = {}, currentUser = {}) => ...@@ -369,7 +503,7 @@ export const upsertInitiation = (projectCode, payload = {}, currentUser = {}) =>
'fundSource', 'fundSource',
'executionPlan', 'executionPlan',
'expectedTenderTime', 'expectedTenderTime',
], currentUser) ], PROJECT_STAGES.INITIATION, currentUser)
export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) => export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, procurements, payload, [ upsertPhase(projectCode, procurements, payload, [
...@@ -382,10 +516,10 @@ export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) = ...@@ -382,10 +516,10 @@ export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) =
'formalBidStatus', 'formalBidStatus',
'winningNoticeFileUrl', 'winningNoticeFileUrl',
'bidArchiveFileUrl', 'bidArchiveFileUrl',
], currentUser) ], PROJECT_STAGES.PROCUREMENT, currentUser)
export const upsertContract = (projectCode, payload = {}, currentUser = {}) => export const upsertContract = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, contracts, payload, ['contractNo', 'contractName', 'amount', 'drafter', 'archiveFileUrl'], currentUser) upsertPhase(projectCode, contracts, payload, ['contractNo', 'contractName', 'amount', 'drafter', 'archiveFileUrl'], PROJECT_STAGES.CONTRACT, currentUser)
export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) => export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, deliveries, payload, [ upsertPhase(projectCode, deliveries, payload, [
...@@ -393,15 +527,13 @@ export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) => ...@@ -393,15 +527,13 @@ export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) =>
'deliveryContact', 'deliveryContact',
'deliveryContactPhone', 'deliveryContactPhone',
'deliveryNote', 'deliveryNote',
'completedAt', ], PROJECT_STAGES.DELIVERY, currentUser)
], currentUser)
export const upsertAcceptance = (projectCode, payload = {}, currentUser = {}) => export const upsertAcceptance = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, acceptances, payload, ['acceptanceReportUrl', 'acceptanceNote'], currentUser) upsertPhase(projectCode, acceptances, payload, ['acceptanceReportUrl', 'acceptanceNote'], PROJECT_STAGES.ACCEPTANCE, currentUser)
export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => { export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => {
const stage = toNumberOrUndefined(payload.toStage) const stage = toNumberOrUndefined(payload.toStage)
const status = PROJECT_STATUSES.ACTIVE
if (stage === undefined) { if (stage === undefined) {
throw httpError(400, 'toStage 不能为空') throw httpError(400, 'toStage 不能为空')
} }
...@@ -412,21 +544,99 @@ export const moveProjectStage = async (projectCode, payload = {}, currentUser = ...@@ -412,21 +544,99 @@ export const moveProjectStage = async (projectCode, payload = {}, currentUser =
if (!project) return if (!project) return
exists = true exists = true
const isNoop = project.stage === stage && project.status === status const status = stage === PROJECT_STAGES.ARCHIVED ? PROJECT_STATUSES.ARCHIVED : PROJECT_STATUSES.ACTIVE
if (!isNoop) { const matchedTransition = isManualTransitionAllowed({
const matchedTransition = isManualTransitionAllowed({ fromStage: project.stage,
fromStage: project.stage, fromStatus: project.status,
fromStatus: project.status, toStage: stage,
toStage: stage, toStatus: status,
toStatus: status, })
}) if (!matchedTransition) throw httpError(400, '不允许的阶段流转')
if (!matchedTransition) {
throw httpError(400, '不允许的阶段流转') const isAdministrator = currentUser.roles?.includes('admin')
if (!isAdministrator) {
const roleByStage = {
[PROJECT_STAGES.SOLUTION]: 'solution_owner',
[PROJECT_STAGES.INITIATION]: 'initiation_owner',
[PROJECT_STAGES.PROCUREMENT]: 'procurement_owner',
[PROJECT_STAGES.CONTRACT]: 'contract_owner',
[PROJECT_STAGES.DELIVERY]: 'delivery_owner',
[PROJECT_STAGES.ACCEPTANCE]: 'acceptance_owner',
}
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager') && !roles.includes(roleByStage[project.stage])) {
throw httpError(403, '您不是当前阶段负责人')
} }
} }
if (project.stage === PROJECT_STAGES.DELIVERY) {
await tx.update(deliveries)
.set({ completedAt: new Date() })
.where(eq(deliveries.projectId, project.id))
}
await validateStageCompletion(tx, project)
await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id)) await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, stage, status, payload.description || '项目阶段已更新', { await appendStageTimeline(tx, project, stage, status, payload.description || '已完成当前阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
const getUserProjectRoles = async (projectId, ssoId, database = db) => {
if (!ssoId) return []
const rows = await database.select({ role: projectRoleAssignments.role })
.from(projectRoleAssignments)
.where(and(eq(projectRoleAssignments.projectId, projectId), eq(projectRoleAssignments.ssoId, ssoId), eq(projectRoleAssignments.status, 1)))
return rows.map((row) => row.role)
}
const validateStageCompletion = async (tx, project) => {
if (project.stage === PROJECT_STAGES.SOLUTION && !project.solution?.trim()) {
throw httpError(400, '方案完成前请填写方案内容')
}
if (project.stage === PROJECT_STAGES.INITIATION) {
const rows = await tx.select({ applicationFileUrl: initiations.applicationFileUrl }).from(initiations).where(eq(initiations.projectId, project.id)).limit(1)
if (!hasFiles(rows[0]?.applicationFileUrl)) throw httpError(400, '立项完成前请上传项目申报书')
}
if (project.stage === PROJECT_STAGES.CONTRACT) {
const rows = await tx.select().from(contracts).where(eq(contracts.projectId, project.id)).limit(1)
const contract = rows[0]
if (!contract?.contractNo?.trim() || !contract.contractName?.trim() || !hasFiles(contract.archiveFileUrl)) {
throw httpError(400, '合同完成前请填写合同编号、合同名称并上传合同文件')
}
}
if (project.stage === PROJECT_STAGES.ACCEPTANCE) {
const rows = await tx.select({ acceptanceReportUrl: acceptances.acceptanceReportUrl }).from(acceptances).where(eq(acceptances.projectId, project.id)).limit(1)
if (!hasFiles(rows[0]?.acceptanceReportUrl)) throw httpError(400, '验收完成前请上传验收报告')
}
}
export const rollbackProjectStage = async (projectCode, reason, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
if (!currentUser.roles?.includes('admin')) {
const roles = await getUserProjectRoles(project.id, currentUser.userId, tx)
if (!roles.includes('project_manager')) throw httpError(403, '只有项目负责人和管理员可以退回阶段')
}
const previousStage = {
[PROJECT_STAGES.INITIATION]: PROJECT_STAGES.SOLUTION,
[PROJECT_STAGES.PROCUREMENT]: PROJECT_STAGES.INITIATION,
[PROJECT_STAGES.CONTRACT]: PROJECT_STAGES.PROCUREMENT,
[PROJECT_STAGES.DELIVERY]: PROJECT_STAGES.CONTRACT,
[PROJECT_STAGES.ACCEPTANCE]: PROJECT_STAGES.DELIVERY,
[PROJECT_STAGES.ARCHIVED]: PROJECT_STAGES.ACCEPTANCE,
}[project.stage]
if (!previousStage) throw httpError(400, '当前阶段不能退回')
await tx.update(projects).set({ stage: previousStage, status: PROJECT_STATUSES.ACTIVE, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, previousStage, PROJECT_STATUSES.ACTIVE, reason || '项目退回上一阶段', {
operatorUserId: currentUser.userId,
operatorName: currentUser.userName || null, operatorName: currentUser.userName || null,
}) })
}) })
...@@ -452,16 +662,3 @@ export const deleteProject = async (projectCode) => { ...@@ -452,16 +662,3 @@ export const deleteProject = async (projectCode) => {
return removedProject return removedProject
} }
const resolveOwnerDingUserId = async ({ ownerDingUserId, ownerUserId, ownerName, ownerMobile }) => {
if (ownerDingUserId) return ownerDingUserId
if (!ownerMobile) return null
const binding = await resolveBindingByMobile({
systemUserId: ownerUserId,
name: ownerName,
mobile: ownerMobile,
})
return binding.dingUserId
}
import { PROJECT_STATUSES, PROJECT_STAGES } from './schema.js' import { PROJECT_STATUSES, PROJECT_STAGES } from './schema.js'
import { APPROVAL_KEYS } from '../approvals/constants.js'
export const MANUAL_STAGE_TRANSITIONS = [ export const MANUAL_STAGE_TRANSITIONS = [
{
fromStage: PROJECT_STAGES.INITIATION,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.PROCUREMENT,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{ {
fromStage: PROJECT_STAGES.SOLUTION, fromStage: PROJECT_STAGES.SOLUTION,
fromStatuses: [PROJECT_STATUSES.ACTIVE], fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.INITIATION, toStage: PROJECT_STAGES.INITIATION,
toStatus: PROJECT_STATUSES.ACTIVE, toStatus: PROJECT_STATUSES.ACTIVE,
}, },
{
fromStage: PROJECT_STAGES.CONTRACT,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.DELIVERY,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{ {
fromStage: PROJECT_STAGES.PROCUREMENT, fromStage: PROJECT_STAGES.PROCUREMENT,
fromStatuses: [PROJECT_STATUSES.ACTIVE], fromStatuses: [PROJECT_STATUSES.ACTIVE],
...@@ -20,22 +30,13 @@ export const MANUAL_STAGE_TRANSITIONS = [ ...@@ -20,22 +30,13 @@ export const MANUAL_STAGE_TRANSITIONS = [
toStage: PROJECT_STAGES.ACCEPTANCE, toStage: PROJECT_STAGES.ACCEPTANCE,
toStatus: PROJECT_STATUSES.ACTIVE, toStatus: PROJECT_STATUSES.ACTIVE,
}, },
] {
fromStage: PROJECT_STAGES.ACCEPTANCE,
export const APPROVAL_START_RULES = { fromStatuses: [PROJECT_STATUSES.ACTIVE],
[APPROVAL_KEYS.PROJECT]: { toStage: PROJECT_STAGES.ARCHIVED,
stage: PROJECT_STAGES.INITIATION, toStatus: PROJECT_STATUSES.ARCHIVED,
statuses: [PROJECT_STATUSES.ACTIVE],
},
[APPROVAL_KEYS.CONTRACT]: {
stage: PROJECT_STAGES.CONTRACT,
statuses: [PROJECT_STATUSES.ACTIVE],
},
[APPROVAL_KEYS.ACCEPTANCE]: {
stage: PROJECT_STAGES.ACCEPTANCE,
statuses: [PROJECT_STATUSES.ACTIVE],
}, },
} ]
export const PROJECT_DELETE_RULE = { export const PROJECT_DELETE_RULE = {
stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION], stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION],
...@@ -51,12 +52,5 @@ export const isManualTransitionAllowed = ({ fromStage, fromStatus, toStage, toSt ...@@ -51,12 +52,5 @@ export const isManualTransitionAllowed = ({ fromStage, fromStatus, toStage, toSt
item.fromStatuses.includes(fromStatus), item.fromStatuses.includes(fromStatus),
) )
export const isApprovalStartAllowed = ({ approvalKey, stage, status }) => {
const rule = APPROVAL_START_RULES[approvalKey]
if (!rule) return false
return rule.stage === stage && rule.statuses.includes(status)
}
export const isProjectDeleteAllowed = ({ stage, status }) => export const isProjectDeleteAllowed = ({ stage, status }) =>
PROJECT_DELETE_RULE.stages.includes(stage) && PROJECT_DELETE_RULE.statuses.includes(status) PROJECT_DELETE_RULE.stages.includes(stage) && PROJECT_DELETE_RULE.statuses.includes(status)
const getHeader = (req, name) => req.get(name) || null
const decodeHeader = (value) => (value ? decodeURIComponent(value) : null)
export const getCurrentUser = (req) => ({ export const getCurrentUser = (req) => ({
userId: getHeader(req, 'x-user-id'), userId: req.user?.ssoId || null,
userName: decodeHeader(getHeader(req, 'x-user-name')), userName: req.user?.name || null,
dingUserId: getHeader(req, 'x-dingtalk-user-id'), mobile: req.user?.mobile || null,
mobile: getHeader(req, 'x-user-mobile'), roles: req.user?.roles || [],
}) })
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论