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

chore: update

上级 dc447146
......@@ -9,16 +9,13 @@ MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=com_dms
DINGTALK_APP_ID=
DINGTALK_AGENT_ID=
DINGTALK_APP_KEY=
DINGTALK_APP_SECRET=
DINGTALK_CORP_ID=
DINGTALK_STREAM_DEBUG=false
DINGTALK_PROJECT_APPROVAL_PROCESS_CODE=
DINGTALK_CONTRACT_APPROVAL_PROCESS_CODE=
DINGTALK_ACCEPTANCE_APPROVAL_PROCESS_CODE=
WX_APPID_1=
WX_SECRET_1=
# 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=
PERMISSION_API_URL=
PERMISSION_APP_ID=
PERMISSION_APP_SECRET=
PERMISSION_CACHE_TTL_SECONDS=180
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/
| Method | Path | Description |
|--------|------|-------------|
| POST | `/logs` | Create single log |
| POST | `/logs/batch` | Create batch logs |
| GET | `/logs` | Query logs |
| GET | `/logs/stats` | Get log statistics |
| POST | `/api/logs` | Create single log |
| POST | `/api/logs/batch` | Create batch logs |
| GET | `/api/logs` | Query logs |
| GET | `/api/logs/stats` | Get log statistics |
#### 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` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(120) NOT NULL COMMENT '产品名称',
......@@ -23,16 +26,12 @@ CREATE TABLE IF NOT EXISTS `project_list` (
`department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID',
`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_title` varchar(120),
`contact_phone` varchar(64),
`solution` text,
`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,
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
......@@ -42,8 +41,6 @@ CREATE TABLE IF NOT EXISTS `project_list` (
UNIQUE KEY `uk_projects_project_code` (`project_code`),
KEY `idx_projects_stage_status` (`stage`, `status`),
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`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='项目主表';
......@@ -66,8 +63,8 @@ CREATE TABLE IF NOT EXISTS `case_list` (
CREATE TABLE IF NOT EXISTS `project_initiations` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`application_file_url` varchar(500),
`argument_file_url` varchar(500),
`application_file_url` text COMMENT '附件JSON数组',
`argument_file_url` text COMMENT '附件JSON数组',
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
......@@ -89,8 +86,8 @@ CREATE TABLE IF NOT EXISTS `project_procurements` (
`main_bid_owner` varchar(120),
`companion_bidders` text,
`formal_bid_status` varchar(120),
`winning_notice_file_url` varchar(500),
`bid_archive_file_url` varchar(500),
`winning_notice_file_url` text COMMENT '附件JSON数组',
`bid_archive_file_url` text COMMENT '附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
......@@ -105,7 +102,7 @@ CREATE TABLE IF NOT EXISTS `project_contracts` (
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` varchar(500),
`archive_file_url` text COMMENT '附件JSON数组',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
......@@ -131,7 +128,7 @@ CREATE TABLE IF NOT EXISTS `project_deliveries` (
CREATE TABLE IF NOT EXISTS `project_acceptances` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500),
`acceptance_report_url` text COMMENT '附件JSON数组',
`acceptance_note` text,
`created_at` datetime NOT NULL DEFAULT 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` (
`description` text,
`stage` int NOT NULL,
`status` int NOT NULL,
`operator_user_id` varchar(64) COMMENT '操作人系统用户ID',
`operator_name` varchar(120) COMMENT '操作人姓名',
`created_at` datetime NOT NULL DEFAULT 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` (
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='项目时间线记录';
CREATE TABLE IF NOT EXISTS `project_approval_instances` (
CREATE TABLE IF NOT EXISTS `project_role_assignments` (
`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_code` varchar(64) NOT NULL,
`originator_user_id` varchar(64),
`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),
`sso_id` varchar(64) NOT NULL,
`user_name` varchar(120),
`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,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_dingtalk_user_bindings_system_user_id` (`system_user_id`),
UNIQUE KEY `uk_dingtalk_user_bindings_ding_user_id` (`ding_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='钉钉用户绑定关系';
UNIQUE KEY `uk_project_roles_assignment` (`project_id`, `sso_id`, `role`),
KEY `idx_project_roles_project_id` (`project_id`),
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` (
`department_name` varchar(255),
`product_id` bigint unsigned COMMENT '关联产品ID',
`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_title` varchar(120),
`contact_phone` varchar(64),
`solution` text,
`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,
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
......@@ -57,8 +54,8 @@ CREATE TABLE `case_list` (
CREATE TABLE `project_initiations` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`application_file_url` varchar(500),
`argument_file_url` varchar(500),
`application_file_url` text,
`argument_file_url` text,
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
......@@ -79,8 +76,8 @@ CREATE TABLE `project_procurements` (
`main_bid_owner` varchar(120),
`companion_bidders` text,
`formal_bid_status` varchar(120),
`winning_notice_file_url` varchar(500),
`bid_archive_file_url` varchar(500),
`winning_notice_file_url` text,
`bid_archive_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_procurements_id` PRIMARY KEY(`id`),
......@@ -94,7 +91,7 @@ CREATE TABLE `project_contracts` (
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` varchar(500),
`archive_file_url` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_contracts_id` PRIMARY KEY(`id`),
......@@ -118,7 +115,7 @@ CREATE TABLE `project_deliveries` (
CREATE TABLE `project_acceptances` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500),
`acceptance_report_url` text,
`acceptance_note` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
......@@ -134,43 +131,32 @@ CREATE TABLE `project_timelines` (
`description` text,
`stage` int NOT NULL,
`status` int NOT NULL,
`operator_user_id` varchar(64) COMMENT '操作人系统用户ID',
`operator_name` varchar(120) COMMENT '操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_timelines_id` PRIMARY KEY(`id`)
) COMMENT='项目时间线记录';
--> statement-breakpoint
CREATE TABLE `project_approval_instances` (
CREATE TABLE `project_role_assignments` (
`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_code` varchar(64) NOT NULL,
`originator_user_id` varchar(64),
`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),
`sso_id` varchar(64) NOT NULL,
`user_name` varchar(120),
`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,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `dingtalk_user_bindings_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_dingtalk_user_bindings_system_user_id` UNIQUE(`system_user_id`),
CONSTRAINT `uk_dingtalk_user_bindings_ding_user_id` UNIQUE(`ding_user_id`)
) COMMENT='钉钉用户绑定关系';
CONSTRAINT `project_role_assignments_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_project_roles_assignment` UNIQUE(`project_id`,`sso_id`,`role`)
) COMMENT='项目角色分工';
--> 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;
--> statement-breakpoint
......@@ -184,7 +170,8 @@ ALTER TABLE `project_acceptances` ADD CONSTRAINT `project_acceptances_project_id
--> 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;
--> 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
CREATE INDEX `idx_products_status` ON `product_list` (`status`);
--> statement-breakpoint
......@@ -194,10 +181,6 @@ CREATE INDEX `idx_projects_stage_status` ON `project_list` (`stage`,`status`);
--> statement-breakpoint
CREATE INDEX `idx_projects_product_id` ON `project_list` (`product_id`);
--> 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`);
--> statement-breakpoint
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
--> statement-breakpoint
CREATE INDEX `idx_project_timelines_created_at` ON `project_timelines` (`created_at`);
--> 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
CREATE INDEX `idx_approval_instances_project_code` ON `project_approval_instances` (`project_code`);
--> statement-breakpoint
CREATE INDEX `idx_approval_instances_status` ON `project_approval_instances` (`status`);
......@@ -13,10 +13,10 @@
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"dayjs": "^1.11.19",
"dingtalk-stream": "^2.1.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"express-http-proxy": "^2.1.2",
"mongoose": "^8.23.1",
"mysql2": "^3.20.0",
"pino": "^10.3.1",
......@@ -1629,17 +1629,6 @@
],
"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": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
......@@ -1877,6 +1866,12 @@
"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": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
......@@ -2252,6 +2247,56 @@
"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": {
"version": "1.54.0",
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz",
......@@ -4537,27 +4582,6 @@
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"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": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
......
......@@ -7,12 +7,10 @@
"scripts": {
"start": "node 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:migrate": "drizzle-kit migrate",
"lint": "eslint 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"
},
"author": "King",
......@@ -31,10 +29,10 @@
"cors": "^2.8.5",
"cross-env": "^7.0.3",
"dayjs": "^1.11.19",
"dingtalk-stream": "^2.1.5",
"dotenv": "^16.4.5",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"express-http-proxy": "^2.1.2",
"mongoose": "^8.23.1",
"mysql2": "^3.20.0",
"pino": "^10.3.1",
......
import express from 'express'
import cors from 'cors'
import proxy from 'express-http-proxy'
import pinoHttp from 'pino-http'
import logger from './lib/logger.js'
import { notFound, errorHandler } from './middleware/error.js'
......@@ -18,6 +19,7 @@ app.use(pinoHttp({ logger, autoLogging: process.env.NODE_ENV !== 'test' }))
// Middleware
app.use(cors({ credentials: true }))
app.use('/api/usercenter', proxy('https://api-usercenter.ezijing.com'))
// 增加请求体大小限制,支持批量日志上报(默认 100kb,增加到 10mb)
app.use(express.json({ 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()
// Modules
app.use(wechatRoutes)
app.use(wxChartRoutes)
app.use(logsRoutes)
app.use(dmsRoutes)
app.use('/api', logsRoutes)
app.use('/api', dmsRoutes)
// Error handling
app.use(notFound)
......
......@@ -16,27 +16,28 @@ const config = {
password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'com_dms',
},
dingtalk: {
appId: process.env.DINGTALK_APP_ID || '',
corpId: process.env.DINGTALK_CORP_ID || '',
appKey: process.env.DINGTALK_APP_KEY || '',
appSecret: process.env.DINGTALK_APP_SECRET || '',
agentId: process.env.DINGTALK_AGENT_ID || '',
streamDebug: process.env.DINGTALK_STREAM_DEBUG === 'true',
processCodes: {
projectApproval: process.env.DINGTALK_PROJECT_APPROVAL_PROCESS_CODE || '',
contractApproval: process.env.DINGTALK_CONTRACT_APPROVAL_PROCESS_CODE || '',
acceptanceApproval: process.env.DINGTALK_ACCEPTANCE_APPROVAL_PROCESS_CODE || '',
dmsAuth: {
disabled: process.env.DMS_AUTH_DISABLED === 'true',
ssoUserInfoUrl:
process.env.SSO_USER_INFO_URL || 'https://api-usercenter.ezijing.com/v2/frontend/user/get-user-info',
userCenterBatchUrl:
process.env.USER_CENTER_BATCH_URL || 'https://api-usercenter.ezijing.com/v2/server/user/multi-get-user-info',
userCenterSalt: process.env.USER_CENTER_SALT || '',
permissionApiUrl: process.env.PERMISSION_API_URL || '',
permissionAppId: process.env.PERMISSION_APP_ID || '',
permissionAppSecret: process.env.PERMISSION_APP_SECRET || '',
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
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 { 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) => {
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 = {}) => ({
operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null,
......@@ -70,7 +62,7 @@ export const getCaseById = async (id) => {
}
export const createCase = async (payload = {}, currentUser = {}) => {
const values = normalizePayload(payload)
const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
validate(values)
const [result] = await db.insert(cases).values({
...values,
......@@ -84,7 +76,7 @@ export const updateCase = async (id, payload = {}, currentUser = {}) => {
const record = await getCaseById(id)
if (!record) return null
const values = normalizePayload(payload)
const values = { ...payload, files: JSON.stringify(payload.files ?? []) }
validate(values)
await db
.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 { validate } from '../../lib/validate.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 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 {
acceptanceUpsertBody,
approvalInstanceIdParam,
caseCreateBody,
casesListQuery,
caseUpdateBody,
......@@ -25,82 +28,87 @@ import {
productUpdateBody,
productUpdateStatusBody,
projectCodeParam,
projectCodeWithApprovalTypeParam,
projectCreateBody,
projectsListQuery,
projectUpdateBody,
systemUserIdParam,
userBindingUpsertBody,
projectTeamBody,
rollbackStageBody,
solutionUpsertBody,
} from './dms.validations.js'
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 ──
router.get('/dms/products', validate({ query: productsListQuery }), productsController.list)
router.post('/dms/products', validate({ body: productCreateBody }), productsController.create)
router.put('/dms/products/:id', validate({ params: idParam, body: productUpdateBody }), productsController.update)
router.put('/dms/products/:id/status', validate({ params: idParam, body: productUpdateStatusBody }), productsController.updateStatus)
router.delete('/dms/products/:id', validate({ params: idParam }), productsController.remove)
router.get(
'/dms/products',
requireAnyDmsRouteAccess,
validate({ query: productsListQuery }),
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 ──
router.get('/dms/cases', validate({ query: casesListQuery }), casesController.list)
router.post('/dms/cases', validate({ body: caseCreateBody }), casesController.create)
router.get('/dms/cases/:id', validate({ params: idParam }), casesController.detail)
router.put('/dms/cases/:id', validate({ params: idParam, body: caseUpdateBody }), casesController.update)
router.delete('/dms/cases/:id', validate({ params: idParam }), casesController.remove)
const casesRouter = Router()
casesRouter.get('/', validate({ query: casesListQuery }), casesController.list)
casesRouter.post('/', validate({ body: caseCreateBody }), casesController.create)
casesRouter.get('/:id', validate({ params: idParam }), casesController.detail)
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 ──
router.post('/dms/projects', validate({ body: projectCreateBody }), projectsController.create)
router.get('/dms/projects', validate({ query: projectsListQuery }), projectsController.list)
router.get('/dms/projects/:projectCode', validate({ params: projectCodeParam }), projectsController.detail)
router.put('/dms/projects/:projectCode', validate({ params: projectCodeParam, body: projectUpdateBody }), projectsController.update)
router.delete('/dms/projects/:projectCode', validate({ params: projectCodeParam }), projectsController.remove)
router.put('/dms/projects/:projectCode/stage', validate({ params: projectCodeParam, body: moveStageBody }), projectsController.moveStage)
const projectsRouter = Router()
projectsRouter.post('/', validate({ body: projectCreateBody }), projectsController.create)
projectsRouter.get('/', validate({ query: projectsListQuery }), projectsController.list)
projectsRouter.get('/:projectCode', validate({ params: projectCodeParam }), requireProjectAccess, projectsController.detail)
projectsRouter.put('/:projectCode', validate({ params: projectCodeParam, body: projectUpdateBody }), requireProjectRole('project_manager'), projectsController.update)
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(
'/dms/projects/:projectCode/initiation',
projectsRouter.get('/:projectCode/team', validate({ params: projectCodeParam }), requireProjectAccess, projectRolesController.list)
projectsRouter.put('/:projectCode/team', validate({ params: projectCodeParam, body: projectTeamBody }), requireProjectRole('project_manager'), projectRolesController.replace)
projectsRouter.put(
'/:projectCode/initiation',
validate({ params: projectCodeParam, body: initiationUpsertBody }),
requireProjectRole('initiation_owner', 'project_manager'),
projectsController.upsertInitiation,
)
router.put(
'/dms/projects/:projectCode/procurement',
projectsRouter.put(
'/:projectCode/procurement',
validate({ params: projectCodeParam, body: procurementUpsertBody }),
requireProjectRole('procurement_owner', 'project_manager'),
projectsController.upsertProcurement,
)
router.put(
'/dms/projects/:projectCode/contract',
projectsRouter.put(
'/:projectCode/contract',
validate({ params: projectCodeParam, body: contractUpsertBody }),
requireProjectRole('contract_owner', 'project_manager'),
projectsController.upsertContract,
)
router.put(
'/dms/projects/:projectCode/delivery',
projectsRouter.put(
'/:projectCode/delivery',
validate({ params: projectCodeParam, body: deliveryUpsertBody }),
requireProjectRole('delivery_owner', 'project_manager'),
projectsController.upsertDelivery,
)
router.put(
'/dms/projects/:projectCode/acceptance',
projectsRouter.put(
'/:projectCode/acceptance',
validate({ params: projectCodeParam, body: acceptanceUpsertBody }),
requireProjectRole('acceptance_owner', 'project_manager'),
projectsController.upsertAcceptance,
)
// ── 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)
router.use('/dms/projects', requireRouteAccess('/dms/projects'), projectsRouter)
export default router
......@@ -7,6 +7,27 @@ const pagination = {
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 ─────────────────────────────────────────────────────────
export const productsListQuery = z.object({
......@@ -15,15 +36,14 @@ export const productsListQuery = z.object({
...pagination,
})
export const productCreateBody = z.object({
name: z.string().min(1).max(120),
description: z.string().optional(),
const productBody = z.object({
name: z.string().trim().min(1).max(120),
status: z.coerce.number().int().optional(),
description: z.string().trim().optional(),
})
export const productUpdateBody = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().optional(),
})
export const productCreateBody = productBody
export const productUpdateBody = productBody
export const productUpdateStatusBody = z.object({
status: z.number().int(),
......@@ -41,15 +61,16 @@ export const casesListQuery = z.object({
...pagination,
})
export const caseCreateBody = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
productId: z.coerce.number().int().optional(),
const caseBody = z.object({
name: z.string().trim().min(1).max(255),
description: z.string().trim().optional(),
productId: optionalId,
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 ─────────────────────────────────────────────────────────
......@@ -57,7 +78,6 @@ export const projectsListQuery = z.object({
keyword: z.string().optional(),
stage: z.coerce.number().int().optional(),
status: z.coerce.number().int().optional(),
ownerUserId: z.string().optional(),
...pagination,
})
......@@ -66,17 +86,13 @@ export const projectCodeParam = z.object({
})
export const projectCreateBody = z.object({
name: z.string().min(1).max(255),
province: z.string().max(120).optional(),
city: z.string().max(120).optional(),
schoolName: z.string().max(255).optional(),
name: z.string().trim().min(1).max(255),
province: z.string().trim().min(1).max(120),
city: z.string().trim().min(1).max(120),
schoolName: z.string().trim().min(1).max(255),
departmentName: z.string().max(255).optional(),
productId: z.coerce.number().int().optional(),
productId: z.coerce.number().int().positive(),
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(),
contactTitle: z.string().max(120).optional(),
contactPhone: z.string().max(64).optional(),
......@@ -85,75 +101,70 @@ export const projectCreateBody = z.object({
export const projectUpdateBody = projectCreateBody.partial()
export const solutionUpsertBody = z.object({
solution: z.string().nullish(),
})
export const moveStageBody = z.object({
toStage: z.coerce.number().int(),
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 ────────────────────────────────────────────────────
export const initiationUpsertBody = z.object({
applicationFileUrl: z.string().max(500).optional(),
argumentFileUrl: z.string().max(500).optional(),
projectAmount: z.coerce.number().optional(),
fundSource: z.string().max(255).optional(),
executionPlan: z.string().optional(),
expectedTenderTime: z.string().max(120).optional(),
applicationFileUrl: fileListValue,
argumentFileUrl: fileListValue,
projectAmount: optionalNumber,
fundSource: z.string().max(255).nullish(),
executionPlan: z.string().nullish(),
expectedTenderTime: z.string().max(120).nullish(),
})
export const procurementUpsertBody = z.object({
tenderOwner: z.string().max(120).optional(),
businessParams: z.string().optional(),
technicalParams: z.string().optional(),
intentionPublicity: z.string().max(255).optional(),
mainBidOwner: z.string().max(120).optional(),
companionBidders: z.string().optional(),
formalBidStatus: z.string().max(120).optional(),
winningNoticeFileUrl: z.string().max(500).optional(),
bidArchiveFileUrl: z.string().max(500).optional(),
tenderOwner: z.string().max(120).nullish(),
businessParams: z.string().nullish(),
technicalParams: z.string().nullish(),
intentionPublicity: z.string().max(255).nullish(),
mainBidOwner: z.string().max(120).nullish(),
companionBidders: z.string().nullish(),
formalBidStatus: z.string().max(120).nullish(),
winningNoticeFileUrl: fileListValue,
bidArchiveFileUrl: fileListValue,
})
export const contractUpsertBody = z.object({
contractNo: z.string().max(128).optional(),
contractName: z.string().max(255).optional(),
amount: z.coerce.number().optional(),
drafter: z.string().max(120).optional(),
archiveFileUrl: z.string().max(500).optional(),
contractNo: z.string().max(128).nullish(),
contractName: z.string().max(255).nullish(),
amount: optionalNumber,
drafter: z.string().max(120).nullish(),
archiveFileUrl: fileListValue,
})
export const deliveryUpsertBody = z.object({
deliveryAddress: z.string().max(255).optional(),
deliveryContact: z.string().max(120).optional(),
deliveryContactPhone: z.string().max(64).optional(),
deliveryNote: z.string().optional(),
completedAt: z.string().optional(),
deliveryAddress: z.string().max(255).nullish(),
deliveryContact: z.string().max(120).nullish(),
deliveryContactPhone: z.string().max(64).nullish(),
deliveryNote: z.string().nullish(),
})
export const acceptanceUpsertBody = z.object({
acceptanceReportUrl: z.string().max(500).optional(),
acceptanceNote: z.string().optional(),
})
// ── 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(),
acceptanceReportUrl: fileListValue,
acceptanceNote: z.string().nullish(),
})
import { and, count, desc, eq, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.js'
import { cases } from '../cases/schema.js'
import { projects } from '../projects/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 = {}) => ({
operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null,
......@@ -58,7 +53,7 @@ export const getProductById = async (id) => {
}
export const createProduct = async (payload = {}, currentUser = {}) => {
const values = normalizeProductPayload(payload)
const values = payload
validateProduct(values)
const [result] = await db.insert(products).values({
...values,
......@@ -72,7 +67,7 @@ export const updateProduct = async (id, payload = {}, currentUser = {}) => {
const product = await getProductById(id)
if (!product) return null
const values = normalizeProductPayload(payload)
const values = payload
validateProduct(values)
await db
.update(products)
......@@ -107,9 +102,12 @@ export const updateProductStatus = async (id, payload = {}, currentUser = {}) =>
export const deleteProduct = async (id) => {
const product = await getProductById(id)
if (!product) return null
const usedRows = await db.select({ total: count() }).from(projects).where(eq(projects.productId, Number(id)))
if (usedRows[0]?.total) {
throw httpError(400, '产品已被项目使用,不能删除')
const [projectRows, caseRows] = await Promise.all([
db.select({ total: count() }).from(projects).where(eq(projects.productId, Number(id))),
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)))
......@@ -121,7 +119,7 @@ const validateProduct = (values) => {
if (!values.name) {
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, '产品状态不正确')
}
}
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 { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
import { listAssignments } from '../project-roles/service.js'
export const create = async (req, res) => {
const project = await projectsService.createProject(req.body, getCurrentUser(req))
......@@ -8,15 +9,20 @@ export const create = async (req, res) => {
}
export const list = async (req, res) => {
const { keyword, stage, status, ownerUserId, page, limit } = req.query
const result = await projectsService.listProjects({ keyword, stage, status, ownerUserId }, { page, limit })
const { keyword, stage, status, page, limit } = req.query
const result = await projectsService.listProjects(
{ keyword, stage, status },
{ page, limit },
req.user ? { userId: req.user.ssoId, roles: req.user.roles || [] } : {},
)
success(res, result)
}
export const detail = async (req, res) => {
const project = await projectsService.getProjectDetail(req.params.projectCode)
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) => {
......@@ -36,6 +42,7 @@ export const upsertProcurement = upsertPhase(projectsService.upsertProcurement)
export const upsertContract = upsertPhase(projectsService.upsertContract)
export const upsertDelivery = upsertPhase(projectsService.upsertDelivery)
export const upsertAcceptance = upsertPhase(projectsService.upsertAcceptance)
export const updateSolution = upsertPhase(projectsService.updateSolution)
export const moveStage = async (req, res) => {
const project = await projectsService.moveProjectStage(req.params.projectCode, req.body, getCurrentUser(req))
......@@ -43,6 +50,12 @@ export const moveStage = async (req, res) => {
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) => {
const project = await projectsService.deleteProject(req.params.projectCode)
if (!project) return error(res, '项目不存在', 404)
......
......@@ -23,7 +23,6 @@ export const PROJECT_STAGES = {
export const PROJECT_STATUSES = {
ACTIVE: 0,
PENDING: 10,
ARCHIVED: 20,
}
......@@ -39,10 +38,6 @@ export const projects = mysqlTable(
departmentName: varchar('department_name', { length: 255 }),
productId: bigint('product_id', { mode: 'number', unsigned: true }),
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 }),
contactTitle: varchar('contact_title', { length: 120 }),
contactPhone: varchar('contact_phone', { length: 64 }),
......@@ -58,8 +53,6 @@ export const projects = mysqlTable(
projectCodeIdx: uniqueIndex('uk_projects_project_code').on(table.projectCode),
stageStatusIdx: index('idx_projects_stage_status').on(table.stage, table.status),
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),
})
)
......@@ -71,8 +64,8 @@ export const initiations = mysqlTable(
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
applicationFileUrl: varchar('application_file_url', { length: 500 }),
argumentFileUrl: varchar('argument_file_url', { length: 500 }),
applicationFileUrl: text('application_file_url'),
argumentFileUrl: text('argument_file_url'),
projectAmount: decimal('project_amount', { precision: 14, scale: 2 }),
fundSource: varchar('fund_source', { length: 255 }),
executionPlan: text('execution_plan'),
......@@ -98,8 +91,8 @@ export const procurements = mysqlTable(
mainBidOwner: varchar('main_bid_owner', { length: 120 }),
companionBidders: text('companion_bidders'),
formalBidStatus: varchar('formal_bid_status', { length: 120 }),
winningNoticeFileUrl: varchar('winning_notice_file_url', { length: 500 }),
bidArchiveFileUrl: varchar('bid_archive_file_url', { length: 500 }),
winningNoticeFileUrl: text('winning_notice_file_url'),
bidArchiveFileUrl: text('bid_archive_file_url'),
...timestamps,
},
(table) => ({
......@@ -118,7 +111,7 @@ export const contracts = mysqlTable(
contractName: varchar('contract_name', { length: 255 }),
amount: decimal('amount', { precision: 14, scale: 2 }),
drafter: varchar('drafter', { length: 120 }),
archiveFileUrl: varchar('archive_file_url', { length: 500 }),
archiveFileUrl: text('archive_file_url'),
...timestamps,
},
(table) => ({
......@@ -146,7 +139,7 @@ export const acceptances = mysqlTable('project_acceptances', {
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
acceptanceReportUrl: varchar('acceptance_report_url', { length: 500 }),
acceptanceReportUrl: text('acceptance_report_url'),
acceptanceNote: text('acceptance_note'),
...timestamps,
}, (table) => ({
......@@ -165,6 +158,7 @@ export const projectTimelines = mysqlTable(
description: text('description'),
stage: int('stage').notNull(),
status: int('status').notNull(),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
...timestamps,
},
......
import { PROJECT_STATUSES, PROJECT_STAGES } from './schema.js'
import { APPROVAL_KEYS } from '../approvals/constants.js'
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,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.INITIATION,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{
fromStage: PROJECT_STAGES.CONTRACT,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.DELIVERY,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{
fromStage: PROJECT_STAGES.PROCUREMENT,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
......@@ -20,22 +30,13 @@ export const MANUAL_STAGE_TRANSITIONS = [
toStage: PROJECT_STAGES.ACCEPTANCE,
toStatus: PROJECT_STATUSES.ACTIVE,
},
]
export const APPROVAL_START_RULES = {
[APPROVAL_KEYS.PROJECT]: {
stage: PROJECT_STAGES.INITIATION,
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],
{
fromStage: PROJECT_STAGES.ACCEPTANCE,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.ARCHIVED,
toStatus: PROJECT_STATUSES.ARCHIVED,
},
}
]
export const PROJECT_DELETE_RULE = {
stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION],
......@@ -51,12 +52,5 @@ export const isManualTransitionAllowed = ({ fromStage, fromStatus, toStage, toSt
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 }) =>
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) => ({
userId: getHeader(req, 'x-user-id'),
userName: decodeHeader(getHeader(req, 'x-user-name')),
dingUserId: getHeader(req, 'x-dingtalk-user-id'),
mobile: getHeader(req, 'x-user-mobile'),
userId: req.user?.ssoId || null,
userName: req.user?.name || null,
mobile: req.user?.mobile || null,
roles: req.user?.roles || [],
})
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论