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

feat: add workflow management and user utilities

- Implemented manual stage transitions and approval rules in workflow.js. - Added current user retrieval utility in current-user.js. - Refactored logs controller to simplify error handling and improve readability. - Introduced validation for logs routes using zod. - Enhanced wechat controller with improved error handling and validation. - Created wx-chart controller and routes for managing chart data. - Added test-watch.mjs for application loading confirmation.
上级 9e258d66
SERVER_PORT=4101
MONGODB_URI=
DATABASE_URL=
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
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=
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
node_modules/ node_modules/
# Environment variables # Environment variables
.env
.env.local .env.local
.env.*.local .env.*.local
......
# ezijing-node-server # ezijing-node-server
Modern Node.js API server for WeChat services and log collection. Modern Node.js API server for DMS, WeChat services, chart data, and log collection.
## Quick Start ## Quick Start
...@@ -8,9 +8,6 @@ Modern Node.js API server for WeChat services and log collection. ...@@ -8,9 +8,6 @@ Modern Node.js API server for WeChat services and log collection.
# Install dependencies # Install dependencies
npm install npm install
# Start MongoDB (required)
mongod --dbpath /path/to/data
# Development (with hot reload) # Development (with hot reload)
npm run dev npm run dev
...@@ -27,10 +24,12 @@ src/ ...@@ -27,10 +24,12 @@ src/
├── config.js # Configuration ├── config.js # Configuration
├── lib/ # Shared utilities ├── lib/ # Shared utilities
│ ├── logger.js # Pino logger │ ├── logger.js # Pino logger
│ ├── db.js # MongoDB connection │ ├── mongo.js # MongoDB connection
│ └── file.js # File utilities │ └── file.js # File utilities
├── middleware/ # Global middleware ├── middleware/ # Global middleware
├── db/ # MySQL Drizzle runtime client
└── modules/ # Feature modules └── modules/ # Feature modules
├── dms/ # DMS app
├── wechat/ # WeChat SDK ├── wechat/ # WeChat SDK
├── wx-chart/ # WeChat chart data ├── wx-chart/ # WeChat chart data
└── logs/ # Log collection └── logs/ # Log collection
...@@ -99,7 +98,17 @@ SERVER_PORT=4101 ...@@ -99,7 +98,17 @@ SERVER_PORT=4101
DATA_DIR=../node-server-data DATA_DIR=../node-server-data
# MongoDB # MongoDB
MONGODB_URI=mongodb://localhost:27017/ezijing-logs MONGODB_URI=
# MySQL / Drizzle
DATABASE_URL=mysql://root:password@127.0.0.1:3306/com_dms
# Legacy MySQL fallback when DATABASE_URL is not set
MYSQL_HOST=127.0.0.1
MYSQL_PORT=3306
MYSQL_USER=root
MYSQL_PASSWORD=
MYSQL_DATABASE=com_dms
# WeChat config (numbered format) # WeChat config (numbered format)
WX_APPID_1=your_appid WX_APPID_1=your_appid
......
import 'dotenv/config'
import { defineConfig } from 'drizzle-kit'
const databaseUrl = process.env.DATABASE_URL
export default defineConfig({
dialect: 'mysql',
schema: './src/modules/dms/**/schema.js',
out: './migrations/drizzle',
dbCredentials: databaseUrl ? {
url: databaseUrl,
} : {
host: process.env.MYSQL_HOST || '127.0.0.1',
port: parseInt(process.env.MYSQL_PORT || '3306', 10) || 3306,
user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || undefined,
database: process.env.MYSQL_DATABASE || 'com_dms',
},
})
-- DMS database bootstrap.
-- Do not execute this file without explicit confirmation.
CREATE DATABASE IF NOT EXISTS com_dms DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS `product_list` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(120) NOT NULL COMMENT '产品名称',
`status` int NOT NULL DEFAULT 10 COMMENT '产品状态: 0停用 10启用',
`description` text COMMENT '产品说明',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_products_name` (`name`),
KEY `idx_products_status` (`status`),
KEY `idx_products_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='产品信息';
CREATE TABLE IF NOT EXISTS `project_list` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_code` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`province` varchar(120),
`city` varchar(120),
`school_name` varchar(255),
`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已归档',
`description` text,
`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,
PRIMARY KEY (`id`),
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='项目主表';
CREATE TABLE IF NOT EXISTS `case_list` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`name` varchar(255) NOT NULL COMMENT '案例名称',
`description` text COMMENT '案例简介',
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255) COMMENT '产品名称',
`files` longtext COMMENT '案例附件JSON',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
KEY `idx_cases_product_id` (`product_id`),
KEY `idx_cases_operator_user_id` (`operator_user_id`)
) DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='案例信息';
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),
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
`expected_tender_time` varchar(120),
`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_initiations_project_id` (`project_id`),
CONSTRAINT `project_initiations_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_procurements` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`tender_owner` varchar(120),
`business_params` text,
`technical_params` text,
`intention_publicity` varchar(255),
`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),
`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_procurements_project_id` (`project_id`),
CONSTRAINT `project_procurements_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_contracts` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`contract_no` varchar(128),
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` varchar(500),
`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_contracts_project_id` (`project_id`),
CONSTRAINT `project_contracts_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_deliveries` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`delivery_address` varchar(255),
`delivery_contact` varchar(120),
`delivery_contact_phone` varchar(64),
`delivery_note` text,
`completed_at` datetime,
`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_deliveries_project_id` (`project_id`),
CONSTRAINT `project_deliveries_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_acceptances` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500),
`acceptance_note` 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_acceptances_project_id` (`project_id`),
CONSTRAINT `project_acceptances_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_timelines` (
`id` bigint unsigned NOT NULL AUTO_INCREMENT,
`project_id` bigint unsigned NOT NULL,
`project_code` varchar(64) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`stage` int NOT NULL,
`status` int NOT NULL,
`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,
PRIMARY KEY (`id`),
KEY `idx_project_timelines_project_id` (`project_id`),
KEY `idx_project_timelines_project_code` (`project_code`),
KEY `idx_project_timelines_created_at` (`created_at`),
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` (
`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),
`mobile` varchar(64),
`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='钉钉用户绑定关系';
CREATE TABLE `product_list` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL COMMENT '主键',
`name` varchar(120) NOT NULL COMMENT '产品名称',
`status` int NOT NULL DEFAULT 10 COMMENT '产品状态: 0停用 10启用',
`description` text COMMENT '产品说明',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
CONSTRAINT `product_list_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_products_name` UNIQUE(`name`)
) COMMENT='产品信息';
--> statement-breakpoint
CREATE TABLE `project_list` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_code` varchar(64) NOT NULL,
`name` varchar(255) NOT NULL,
`province` varchar(120),
`city` varchar(120),
`school_name` varchar(255),
`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已归档',
`description` text,
`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_list_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_projects_project_code` UNIQUE(`project_code`)
) COMMENT='项目主表';
--> statement-breakpoint
CREATE TABLE `case_list` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL COMMENT '主键',
`name` varchar(255) NOT NULL COMMENT '案例名称',
`description` text COMMENT '案例简介',
`product_id` bigint unsigned COMMENT '关联产品ID',
`product_name` varchar(255) COMMENT '产品名称',
`files` longtext COMMENT '案例附件JSON',
`operator_user_id` varchar(64) COMMENT '最后操作人系统用户ID',
`operator_name` varchar(120) COMMENT '最后操作人姓名',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
CONSTRAINT `case_list_id` PRIMARY KEY(`id`)
) COMMENT='案例信息';
--> statement-breakpoint
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),
`project_amount` decimal(14,2),
`fund_source` varchar(255),
`execution_plan` text,
`expected_tender_time` varchar(120),
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_initiations_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_initiations_project_id` UNIQUE(`project_id`)
) COMMENT='项目立项信息';
--> statement-breakpoint
CREATE TABLE `project_procurements` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`tender_owner` varchar(120),
`business_params` text,
`technical_params` text,
`intention_publicity` varchar(255),
`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),
`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`),
CONSTRAINT `uk_procurements_project_id` UNIQUE(`project_id`)
) COMMENT='项目招投标信息';
--> statement-breakpoint
CREATE TABLE `project_contracts` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`contract_no` varchar(128),
`contract_name` varchar(255),
`amount` decimal(14,2),
`drafter` varchar(120),
`archive_file_url` varchar(500),
`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`),
CONSTRAINT `uk_contracts_project_id` UNIQUE(`project_id`)
) COMMENT='项目合同信息';
--> statement-breakpoint
CREATE TABLE `project_deliveries` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`delivery_address` varchar(255),
`delivery_contact` varchar(120),
`delivery_contact_phone` varchar(64),
`delivery_note` text,
`completed_at` datetime,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_deliveries_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_deliveries_project_id` UNIQUE(`project_id`)
) COMMENT='项目交付信息';
--> statement-breakpoint
CREATE TABLE `project_acceptances` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`acceptance_report_url` varchar(500),
`acceptance_note` text,
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT `project_acceptances_id` PRIMARY KEY(`id`),
CONSTRAINT `uk_acceptances_project_id` UNIQUE(`project_id`)
) COMMENT='项目验收信息';
--> statement-breakpoint
CREATE TABLE `project_timelines` (
`id` bigint unsigned AUTO_INCREMENT NOT NULL,
`project_id` bigint unsigned NOT NULL,
`project_code` varchar(64) NOT NULL,
`title` varchar(255) NOT NULL,
`description` text,
`stage` int NOT NULL,
`status` int NOT NULL,
`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` (
`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),
`mobile` varchar(64),
`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='钉钉用户绑定关系';
--> 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
ALTER TABLE `project_procurements` ADD CONSTRAINT `project_procurements_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_contracts` ADD CONSTRAINT `project_contracts_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_deliveries` ADD CONSTRAINT `project_deliveries_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_acceptances` ADD CONSTRAINT `project_acceptances_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_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;
--> statement-breakpoint
CREATE INDEX `idx_products_status` ON `product_list` (`status`);
--> statement-breakpoint
CREATE INDEX `idx_products_operator_user_id` ON `product_list` (`operator_user_id`);
--> statement-breakpoint
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`);
--> statement-breakpoint
CREATE INDEX `idx_cases_operator_user_id` ON `case_list` (`operator_user_id`);
--> statement-breakpoint
CREATE INDEX `idx_project_timelines_project_id` ON `project_timelines` (`project_id`);
--> statement-breakpoint
CREATE INDEX `idx_project_timelines_project_code` ON `project_timelines` (`project_code`);
--> 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`);
--> 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`);
{
"version": "7",
"dialect": "mysql",
"entries": [
{
"idx": 0,
"version": "5",
"when": 1776998400000,
"tag": "0000_init_dms_schema",
"breakpoints": true
}
]
}
This source diff could not be displayed because it is too large. You can view the blob instead.
...@@ -7,6 +7,9 @@ ...@@ -7,6 +7,9 @@
"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: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 pm2 start src/index.js -n ezijing-node-server",
...@@ -18,21 +21,25 @@ ...@@ -18,21 +21,25 @@
"node": ">=18.0.0" "node": ">=18.0.0"
}, },
"devDependencies": { "devDependencies": {
"drizzle-kit": "^0.31.10",
"eslint": "^9.15.0", "eslint": "^9.15.0",
"globals": "^15.12.0", "globals": "^15.12.0",
"pino-pretty": "^13.0.0" "pino-pretty": "^13.1.3"
}, },
"dependencies": { "dependencies": {
"axios": "^1.13.0", "axios": "^1.15.2",
"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",
"express": "^4.21.1", "drizzle-orm": "^0.45.2",
"mongoose": "^8.8.0", "express": "^5.2.1",
"mongoose": "^8.23.1",
"mysql2": "^3.20.0", "mysql2": "^3.20.0",
"pino": "^9.5.0", "pino": "^10.3.1",
"pino-http": "^10.3.0", "pino-http": "^11.0.0",
"ua-parser-js": "^2.0.6" "ua-parser-js": "^2.0.6",
"zod": "^4.4.3"
} }
} }
...@@ -6,6 +6,7 @@ import { notFound, errorHandler } from './middleware/error.js' ...@@ -6,6 +6,7 @@ import { notFound, errorHandler } from './middleware/error.js'
import wechatRoutes from './modules/wechat/wechat.routes.js' import wechatRoutes from './modules/wechat/wechat.routes.js'
import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js' import wxChartRoutes from './modules/wx-chart/wx-chart.routes.js'
import logsRoutes from './modules/logs/logs.routes.js' import logsRoutes from './modules/logs/logs.routes.js'
import dmsRoutes from './modules/dms/dms.routes.js'
const app = express() const app = express()
...@@ -28,6 +29,7 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now() ...@@ -28,6 +29,7 @@ app.get('/health', (req, res) => res.json({ status: 'ok', timestamp: Date.now()
app.use(wechatRoutes) app.use(wechatRoutes)
app.use(wxChartRoutes) app.use(wxChartRoutes)
app.use(logsRoutes) app.use(logsRoutes)
app.use(dmsRoutes)
// Error handling // Error handling
app.use(notFound) app.use(notFound)
......
...@@ -6,16 +6,28 @@ const config = { ...@@ -6,16 +6,28 @@ const config = {
port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101, port: parseInt(process.env.SERVER_PORT || '4101', 10) || 4101,
dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'), dataDir: process.env.DATA_DIR || path.resolve(process.cwd(), '../node-server-data'),
mongodb: { mongodb: {
uri: uri: process.env.MONGODB_URI || '',
process.env.MONGODB_URI ||
'mongodb://log:log%402025@dds-2ze70e3ecb9601941614-pub.mongodb.rds.aliyuncs.com:3717/logs_data',
}, },
mysql: { mysql: {
host: process.env.MYSQL_HOST || '172.16.3.199', url: process.env.DATABASE_URL || '',
host: process.env.MYSQL_HOST || '127.0.0.1',
port: parseInt(process.env.MYSQL_PORT || '3306', 10) || 3306, port: parseInt(process.env.MYSQL_PORT || '3306', 10) || 3306,
user: process.env.MYSQL_USER || 'root', user: process.env.MYSQL_USER || 'root',
password: process.env.MYSQL_PASSWORD || 'Wa*3@m3DcyuK', password: process.env.MYSQL_PASSWORD || '',
database: process.env.MYSQL_DATABASE || 'com_resource', 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 || '',
},
}, },
wechat: { apps: {} }, wechat: { apps: {} },
} }
...@@ -27,16 +39,4 @@ while (process.env[`WX_APPID_${i}`] && process.env[`WX_SECRET_${i}`]) { ...@@ -27,16 +39,4 @@ while (process.env[`WX_APPID_${i}`] && process.env[`WX_SECRET_${i}`]) {
i++ i++
} }
// Fallback
if (Object.keys(config.wechat.apps).length === 0) {
config.wechat.apps = {
wxd6109d07f6396e5c: 'd80a330735fc82f3fd6aba425481e8fd',
wxe1849e28e176c67b: '90072420a6b6941b7b8af38afd019b9f',
wxb9651d97aabb1a06: 'fc40521654d4a0157fb1bdf15dce760f',
wx451c01d40d090d7a: 'c740445d23458904dd7cc8f2e1bc2601',
wxb0280dd47666d2e9: 'f5f6e01dfa06da3a29b7fa710a287073',
wxfe67f37095e08d2f: '689d4d5a5c3b1290e8ba7cc6fe872b28',
}
}
export default config export default config
import { drizzle } from 'drizzle-orm/mysql2'
import { getMysqlPool } from '../lib/mysql.js'
export const db = drizzle(getMysqlPool())
import app from './app.js' import app from './app.js'
import config from './config.js' import config from './config.js'
import logger from './lib/logger.js' import logger from './lib/logger.js'
import { connectDB, disconnectDB } from './lib/db.js' import { connectMongo, disconnectMongo } from './lib/mongo.js'
import { closeMysqlPool } from './lib/mysql.js'
const start = async () => { const start = async () => {
try { try {
await connectDB() await connectMongo()
const server = app.listen(config.port, () => { const server = app.listen(config.port, () => {
logger.info({ port: config.port, env: config.env }, 'Server started 🚀') logger.info({ port: config.port, env: config.env }, 'Server started 🚀')
...@@ -15,7 +16,8 @@ const start = async () => { ...@@ -15,7 +16,8 @@ const start = async () => {
const shutdown = async (signal) => { const shutdown = async (signal) => {
logger.info(`${signal} received, shutting down...`) logger.info(`${signal} received, shutting down...`)
server.close(async () => { server.close(async () => {
await disconnectDB() await disconnectMongo()
await closeMysqlPool()
logger.info('Server closed') logger.info('Server closed')
process.exit(0) process.exit(0)
}) })
......
export const httpError = (statusCode, message, extra = {}) => {
const error = new Error(message)
error.statusCode = statusCode
Object.assign(error, extra)
return error
}
...@@ -4,8 +4,12 @@ import logger from './logger.js' ...@@ -4,8 +4,12 @@ import logger from './logger.js'
let isConnected = false let isConnected = false
export const connectDB = async () => { export const connectMongo = async () => {
if (isConnected) return if (isConnected) return
if (!config.mongodb.uri) {
logger.warn('MongoDB disabled: MONGODB_URI is not configured')
return
}
try { try {
await mongoose.connect(config.mongodb.uri) await mongoose.connect(config.mongodb.uri)
...@@ -26,7 +30,7 @@ mongoose.connection.on('error', (err) => { ...@@ -26,7 +30,7 @@ mongoose.connection.on('error', (err) => {
logger.error({ err }, 'MongoDB error') logger.error({ err }, 'MongoDB error')
}) })
export const disconnectDB = async () => { export const disconnectMongo = async () => {
if (!isConnected) return if (!isConnected) return
await mongoose.disconnect() await mongoose.disconnect()
isConnected = false isConnected = false
......
...@@ -5,12 +5,16 @@ let pool ...@@ -5,12 +5,16 @@ let pool
export const getMysqlPool = () => { export const getMysqlPool = () => {
if (!pool) { if (!pool) {
pool = mysql.createPool({ const connectionOptions = config.mysql.url ? { uri: config.mysql.url } : {
host: config.mysql.host, host: config.mysql.host,
port: config.mysql.port, port: config.mysql.port,
user: config.mysql.user, user: config.mysql.user,
password: config.mysql.password, password: config.mysql.password,
database: config.mysql.database, database: config.mysql.database,
}
pool = mysql.createPool({
...connectionOptions,
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0, queueLimit: 0,
...@@ -21,3 +25,9 @@ export const getMysqlPool = () => { ...@@ -21,3 +25,9 @@ export const getMysqlPool = () => {
return pool return pool
} }
export const closeMysqlPool = async () => {
if (!pool) return
await pool.end()
pool = null
}
import { ZodError } from 'zod'
import { httpError } from './http-error.js'
/**
* Express middleware factory — validates req.body / req.query / req.params
* against Zod schemas. Parsed body & params replace the originals for
* downstream type safety. Query is validated but not reassigned (Express 5
* makes req.query getter-only).
*/
export const validate = (schemas) => (req, _res, next) => {
try {
if (schemas.body) req.body = schemas.body.parse(req.body)
if (schemas.query) schemas.query.parse(req.query) // validate only
if (schemas.params) req.params = schemas.params.parse(req.params)
next()
} catch (err) {
if (err instanceof ZodError) {
const message = err.issues
.map((e) => `${e.path.length ? e.path.join('.') + ': ' : ''}${e.message}`)
.join('; ')
return next(httpError(400, message, { details: err.issues }))
}
next(err)
}
}
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 * as casesService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const list = async (req, res) => {
const { keyword, productId, page, limit } = req.query
const result = await casesService.listCases({ keyword, productId }, { page, limit })
success(res, result)
}
export const detail = async (req, res) => {
const record = await casesService.getCaseById(req.params.id)
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
export const create = async (req, res) => {
const record = await casesService.createCase(req.body, getCurrentUser(req))
success(res, record, 201)
}
export const update = async (req, res) => {
const record = await casesService.updateCase(req.params.id, req.body, getCurrentUser(req))
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
export const remove = async (req, res) => {
const record = await casesService.deleteCase(req.params.id)
if (!record) return error(res, '案例不存在', 404)
success(res, record)
}
import { bigint, index, longtext, mysqlTable, text, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js'
export const cases = mysqlTable(
'case_list',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
name: varchar('name', { length: 255 }).notNull(),
description: text('description'),
productId: bigint('product_id', { mode: 'number', unsigned: true }),
productName: varchar('product_name', { length: 255 }),
files: longtext('files'),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
productIdIdx: index('idx_cases_product_id').on(table.productId),
operatorUserIdIdx: index('idx_cases_operator_user_id').on(table.operatorUserId),
})
)
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 './schema.js'
const parseFiles = (row) => {
if (!row) return row
try {
return { ...row, files: row.files ? JSON.parse(row.files) : [] }
} catch {
return { ...row, files: [] }
}
}
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,
})
const buildFilters = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
conditions.push(or(like(cases.name, keyword), like(cases.description, keyword), like(cases.operatorName, keyword)))
}
if (filters.productId) {
conditions.push(eq(cases.productId, Number(filters.productId)))
}
return conditions.length ? and(...conditions) : undefined
}
export const listCases = async (filters = {}, options = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
const where = buildFilters(filters)
const [list, totalRows] = await Promise.all([
db.select().from(cases).where(where).orderBy(desc(cases.updatedAt)).limit(limit).offset(offset),
db.select({ total: count() }).from(cases).where(where),
])
const total = totalRows[0]?.total || 0
return {
list: rowsToList(list),
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
export const getCaseById = async (id) => {
const rows = await db.select().from(cases).where(eq(cases.id, Number(id))).limit(1)
return parseFiles(rows[0]) || null
}
export const createCase = async (payload = {}, currentUser = {}) => {
const values = normalizePayload(payload)
validate(values)
const [result] = await db.insert(cases).values({
...values,
...resolveOperator(currentUser),
})
return getCaseById(result.insertId)
}
export const updateCase = async (id, payload = {}, currentUser = {}) => {
const record = await getCaseById(id)
if (!record) return null
const values = normalizePayload(payload)
validate(values)
await db
.update(cases)
.set({
...values,
...resolveOperator(currentUser),
})
.where(eq(cases.id, Number(id)))
return getCaseById(id)
}
export const deleteCase = async (id) => {
const record = await getCaseById(id)
if (!record) return null
await db.delete(cases).where(eq(cases.id, Number(id)))
return record
}
const validate = (values) => {
if (!values.name) {
throw httpError(400, '案例名称不能为空')
}
}
import { datetime } from 'drizzle-orm/mysql-core'
import { sql } from 'drizzle-orm'
export const timestamps = {
createdAt: datetime('created_at').notNull().default(sql`CURRENT_TIMESTAMP`),
updatedAt: datetime('updated_at')
.notNull()
.default(sql`CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP`),
}
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 {
acceptanceUpsertBody,
approvalInstanceIdParam,
caseCreateBody,
casesListQuery,
caseUpdateBody,
contractUpsertBody,
deliveryUpsertBody,
idParam,
initiationUpsertBody,
moveStageBody,
procurementUpsertBody,
productCreateBody,
productsListQuery,
productUpdateBody,
productUpdateStatusBody,
projectCodeParam,
projectCodeWithApprovalTypeParam,
projectCreateBody,
projectsListQuery,
projectUpdateBody,
systemUserIdParam,
userBindingUpsertBody,
} from './dms.validations.js'
const router = Router()
// ── 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)
// ── 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)
// ── 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)
router.put(
'/dms/projects/:projectCode/initiation',
validate({ params: projectCodeParam, body: initiationUpsertBody }),
projectsController.upsertInitiation,
)
router.put(
'/dms/projects/:projectCode/procurement',
validate({ params: projectCodeParam, body: procurementUpsertBody }),
projectsController.upsertProcurement,
)
router.put(
'/dms/projects/:projectCode/contract',
validate({ params: projectCodeParam, body: contractUpsertBody }),
projectsController.upsertContract,
)
router.put(
'/dms/projects/:projectCode/delivery',
validate({ params: projectCodeParam, body: deliveryUpsertBody }),
projectsController.upsertDelivery,
)
router.put(
'/dms/projects/:projectCode/acceptance',
validate({ params: projectCodeParam, body: acceptanceUpsertBody }),
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)
export default router
import { z } from 'zod'
// ── shared ───────────────────────────────────────────────────────────
const pagination = {
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
}
// ── products ─────────────────────────────────────────────────────────
export const productsListQuery = z.object({
keyword: z.string().optional(),
status: z.coerce.number().int().optional(),
...pagination,
})
export const productCreateBody = z.object({
name: z.string().min(1).max(120),
description: z.string().optional(),
})
export const productUpdateBody = z.object({
name: z.string().min(1).max(120).optional(),
description: z.string().optional(),
})
export const productUpdateStatusBody = z.object({
status: z.number().int(),
})
export const idParam = z.object({
id: z.coerce.number().int().positive(),
})
// ── cases ────────────────────────────────────────────────────────────
export const casesListQuery = z.object({
keyword: z.string().optional(),
productId: z.coerce.number().int().optional(),
...pagination,
})
export const caseCreateBody = z.object({
name: z.string().min(1).max(255),
description: z.string().optional(),
productId: z.coerce.number().int().optional(),
productName: z.string().max(255).optional(),
files: z.array(z.string()).optional(),
})
export const caseUpdateBody = caseCreateBody.partial()
// ── projects ─────────────────────────────────────────────────────────
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,
})
export const projectCodeParam = z.object({
projectCode: z.string().min(1).max(64),
})
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(),
departmentName: z.string().max(255).optional(),
productId: z.coerce.number().int().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(),
contactTitle: z.string().max(120).optional(),
contactPhone: z.string().max(64).optional(),
description: z.string().optional(),
})
export const projectUpdateBody = projectCreateBody.partial()
export const moveStageBody = z.object({
toStage: z.coerce.number().int(),
description: z.string().optional(),
})
// ── 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(),
})
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(),
})
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(),
})
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(),
})
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(),
})
import * as productsService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const list = async (req, res) => {
const { keyword, status, page, limit } = req.query
const result = await productsService.listProducts({ keyword, status }, { page, limit })
success(res, result)
}
export const create = async (req, res) => {
const product = await productsService.createProduct(req.body, getCurrentUser(req))
success(res, product, 201)
}
export const update = async (req, res) => {
const product = await productsService.updateProduct(req.params.id, req.body, getCurrentUser(req))
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
export const updateStatus = async (req, res) => {
const product = await productsService.updateProductStatus(req.params.id, req.body, getCurrentUser(req))
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
export const remove = async (req, res) => {
const product = await productsService.deleteProduct(req.params.id)
if (!product) return error(res, '产品不存在', 404)
success(res, product)
}
import { bigint, index, int, mysqlTable, text, uniqueIndex, varchar } from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js'
export const PRODUCT_STATUSES = {
DISABLED: 0,
ENABLED: 10,
}
export const products = mysqlTable(
'product_list',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
name: varchar('name', { length: 120 }).notNull(),
status: int('status').notNull().default(PRODUCT_STATUSES.ENABLED),
description: text('description'),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
nameIdx: uniqueIndex('uk_products_name').on(table.name),
statusIdx: index('idx_products_status').on(table.status),
operatorUserIdIdx: index('idx_products_operator_user_id').on(table.operatorUserId),
})
)
import { and, count, desc, eq, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.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,
})
const buildFilters = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
conditions.push(or(like(products.name, keyword), like(products.description, keyword), like(products.operatorName, keyword)))
}
if (filters.status !== undefined && filters.status !== '') {
conditions.push(eq(products.status, Number(filters.status)))
}
return conditions.length ? and(...conditions) : undefined
}
export const listProducts = async (filters = {}, options = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
const where = buildFilters(filters)
const [list, totalRows] = await Promise.all([
db.select().from(products).where(where).orderBy(desc(products.updatedAt)).limit(limit).offset(offset),
db.select({ total: count() }).from(products).where(where),
])
const total = totalRows[0]?.total || 0
return {
list,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
export const getProductById = async (id) => {
const rows = await db.select().from(products).where(eq(products.id, Number(id))).limit(1)
return rows[0] || null
}
export const createProduct = async (payload = {}, currentUser = {}) => {
const values = normalizeProductPayload(payload)
validateProduct(values)
const [result] = await db.insert(products).values({
...values,
...resolveOperator(currentUser),
})
return getProductById(result.insertId)
}
export const updateProduct = async (id, payload = {}, currentUser = {}) => {
const product = await getProductById(id)
if (!product) return null
const values = normalizeProductPayload(payload)
validateProduct(values)
await db
.update(products)
.set({
...values,
...resolveOperator(currentUser),
})
.where(eq(products.id, Number(id)))
return getProductById(id)
}
export const updateProductStatus = async (id, payload = {}, currentUser = {}) => {
const product = await getProductById(id)
if (!product) return null
const status = Number(payload.status)
if (![PRODUCT_STATUSES.DISABLED, PRODUCT_STATUSES.ENABLED].includes(status)) {
throw httpError(400, '产品状态不正确')
}
await db
.update(products)
.set({
status,
...resolveOperator(currentUser),
})
.where(eq(products.id, Number(id)))
return getProductById(id)
}
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, '产品已被项目使用,不能删除')
}
await db.delete(products).where(eq(products.id, Number(id)))
return product
}
const validateProduct = (values) => {
if (!values.name) {
throw httpError(400, '产品名称不能为空')
}
if (![PRODUCT_STATUSES.DISABLED, PRODUCT_STATUSES.ENABLED].includes(values.status)) {
throw httpError(400, '产品状态不正确')
}
}
import * as projectsService from './service.js'
import { success, error } from '../../../lib/response.js'
import { getCurrentUser } from '../utils/current-user.js'
export const create = async (req, res) => {
const project = await projectsService.createProject(req.body, getCurrentUser(req))
success(res, project, 201)
}
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 })
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)
}
export const update = async (req, res) => {
const project = await projectsService.updateProject(req.params.projectCode, req.body, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
const upsertPhase = (handler) => async (req, res) => {
const project = await handler(req.params.projectCode, req.body, getCurrentUser(req))
if (!project) return error(res, '项目不存在', 404)
success(res, project)
}
export const upsertInitiation = upsertPhase(projectsService.upsertInitiation)
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 moveStage = async (req, res) => {
const project = await projectsService.moveProjectStage(req.params.projectCode, req.body, 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)
success(res, project)
}
import {
bigint,
datetime,
decimal,
index,
int,
mysqlTable,
text,
uniqueIndex,
varchar,
} from 'drizzle-orm/mysql-core'
import { timestamps } from '../db/columns.js'
export const PROJECT_STAGES = {
SOLUTION: 10,
INITIATION: 20,
PROCUREMENT: 30,
CONTRACT: 40,
DELIVERY: 50,
ACCEPTANCE: 60,
ARCHIVED: 70,
}
export const PROJECT_STATUSES = {
ACTIVE: 0,
PENDING: 10,
ARCHIVED: 20,
}
export const projects = mysqlTable(
'project_list',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectCode: varchar('project_code', { length: 64 }).notNull(),
name: varchar('name', { length: 255 }).notNull(),
province: varchar('province', { length: 120 }),
city: varchar('city', { length: 120 }),
schoolName: varchar('school_name', { length: 255 }),
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 }),
solution: text('solution'),
stage: int('stage').notNull().default(PROJECT_STAGES.SOLUTION),
status: int('status').notNull().default(PROJECT_STATUSES.ACTIVE),
description: text('description'),
operatorUserId: varchar('operator_user_id', { length: 64 }),
operatorName: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
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),
})
)
export const initiations = mysqlTable(
'project_initiations',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
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 }),
projectAmount: decimal('project_amount', { precision: 14, scale: 2 }),
fundSource: varchar('fund_source', { length: 255 }),
executionPlan: text('execution_plan'),
expectedTenderTime: varchar('expected_tender_time', { length: 120 }),
...timestamps,
},
(table) => ({
projectIdIdx: uniqueIndex('uk_initiations_project_id').on(table.projectId),
})
)
export const procurements = mysqlTable(
'project_procurements',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
tenderOwner: varchar('tender_owner', { length: 120 }),
businessParams: text('business_params'),
technicalParams: text('technical_params'),
intentionPublicity: varchar('intention_publicity', { length: 255 }),
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 }),
...timestamps,
},
(table) => ({
projectIdIdx: uniqueIndex('uk_procurements_project_id').on(table.projectId),
})
)
export const contracts = mysqlTable(
'project_contracts',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
contractNo: varchar('contract_no', { length: 128 }),
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 }),
...timestamps,
},
(table) => ({
projectIdIdx: uniqueIndex('uk_contracts_project_id').on(table.projectId),
})
)
export const deliveries = mysqlTable('project_deliveries', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
deliveryAddress: varchar('delivery_address', { length: 255 }),
deliveryContact: varchar('delivery_contact', { length: 120 }),
deliveryContactPhone: varchar('delivery_contact_phone', { length: 64 }),
deliveryNote: text('delivery_note'),
completedAt: datetime('completed_at'),
...timestamps,
}, (table) => ({
projectIdIdx: uniqueIndex('uk_deliveries_project_id').on(table.projectId),
}))
export const acceptances = mysqlTable('project_acceptances', {
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
acceptanceReportUrl: varchar('acceptance_report_url', { length: 500 }),
acceptanceNote: text('acceptance_note'),
...timestamps,
}, (table) => ({
projectIdIdx: uniqueIndex('uk_acceptances_project_id').on(table.projectId),
}))
export const projectTimelines = mysqlTable(
'project_timelines',
{
id: bigint('id', { mode: 'number', unsigned: true }).autoincrement().primaryKey(),
projectId: bigint('project_id', { mode: 'number', unsigned: true })
.notNull()
.references(() => projects.id, { onDelete: 'cascade' }),
projectCode: varchar('project_code', { length: 64 }).notNull(),
title: varchar('title', { length: 255 }).notNull(),
description: text('description'),
stage: int('stage').notNull(),
status: int('status').notNull(),
operatorName: varchar('operator_name', { length: 120 }),
...timestamps,
},
(table) => ({
projectIdIdx: index('idx_project_timelines_project_id').on(table.projectId),
projectCodeIdx: index('idx_project_timelines_project_code').on(table.projectCode),
createdAtIdx: index('idx_project_timelines_created_at').on(table.createdAt),
})
)
import { and, count, desc, eq, like, or } from 'drizzle-orm'
import { httpError } from '../../../lib/http-error.js'
import { db } from '../../../db/index.js'
import {
acceptances,
contracts,
deliveries,
initiations,
procurements,
projects,
PROJECT_STATUSES,
PROJECT_STAGES,
projectTimelines,
} from './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'
const STAGE_LABELS = {
[PROJECT_STAGES.SOLUTION]: '方案阶段',
[PROJECT_STAGES.INITIATION]: '立项阶段',
[PROJECT_STAGES.PROCUREMENT]: '招投标阶段',
[PROJECT_STAGES.CONTRACT]: '合同阶段',
[PROJECT_STAGES.DELIVERY]: '交付阶段',
[PROJECT_STAGES.ACCEPTANCE]: '验收阶段',
[PROJECT_STAGES.ARCHIVED]: '归档',
}
const STATUS_LABELS = {
[PROJECT_STATUSES.ACTIVE]: '进行中',
[PROJECT_STATUSES.PENDING]: '审批中',
[PROJECT_STATUSES.ARCHIVED]: '已归档',
}
const buildProjectCode = () => {
const date = new Date()
const ymd = date.toISOString().slice(0, 10).replaceAll('-', '')
const suffix = Math.random().toString(36).slice(2, 8).toUpperCase()
return `DMS${ymd}${suffix}`
}
const toNumberOrUndefined = (value) => {
if (value === undefined || value === null || value === '') return undefined
const parsed = Number(value)
return Number.isNaN(parsed) ? undefined : parsed
}
const pickDefined = (payload, fields) =>
fields.reduce((values, field) => {
if (payload[field] !== undefined) values[field] = payload[field]
return values
}, {})
const resolveOperator = (currentUser = {}) => ({
operatorUserId: currentUser.userId || null,
operatorName: currentUser.userName || null,
})
const getProjectByCodeWithDb = async (db, projectCode) => {
const rows = await db
.select({ project: projects, productName: products.name })
.from(projects)
.leftJoin(products, eq(projects.productId, products.id))
.where(eq(projects.projectCode, projectCode))
.limit(1)
if (!rows[0]) return null
return {
...rows[0].project,
productName: rows[0].productName || rows[0].project.productName,
}
}
const resolveProductFields = async (db, payload = {}) => {
const productId = toNumberOrUndefined(payload.productId)
if (productId === undefined) {
return payload.productName !== undefined ? { productName: payload.productName || null } : {}
}
const rows = await db.select().from(products).where(eq(products.id, productId)).limit(1)
const product = rows[0]
if (!product) {
throw httpError(400, '产品不存在')
}
return {
productId,
productName: product.name,
}
}
export const appendTimeline = async (db, project, { title, description, stage, status, operatorName }) => {
await db.insert(projectTimelines).values({
projectId: project.id,
projectCode: project.projectCode,
title,
description: description || null,
stage: stage ?? project.stage,
status: status ?? project.status,
operatorName: operatorName || null,
})
}
const appendStageTimeline = async (db, project, stage, status, description, options = {}) => {
await appendTimeline(db, project, {
title: `${STAGE_LABELS[stage] || '项目阶段'}${STATUS_LABELS[status] ? ` - ${STATUS_LABELS[status]}` : ''}`,
description,
stage,
status,
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 = {}) => {
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) => {
const productFields = await resolveProductFields(tx, payload)
const [insertResult] = await tx.insert(projects).values({
projectCode,
name: payload.name,
province: payload.province || null,
city: payload.city || null,
schoolName: payload.schoolName || null,
departmentName: payload.departmentName || null,
productId: productFields.productId || null,
productName: productFields.productName || null,
ownerUserId,
ownerName,
ownerMobile,
ownerDingUserId,
contactName: payload.contactName || null,
contactTitle: payload.contactTitle || null,
contactPhone: payload.contactPhone || null,
solution: payload.solution || null,
stage: toNumberOrUndefined(payload.stage) ?? PROJECT_STAGES.SOLUTION,
status: toNumberOrUndefined(payload.status) ?? PROJECT_STATUSES.ACTIVE,
description: payload.description || null,
...resolveOperator(currentUser),
})
const project = await getProjectByCodeWithDb(tx, projectCode)
const projectId = insertResult.insertId || project.id
await tx.insert(initiations).values({ projectId })
await tx.insert(procurements).values({ projectId })
await tx.insert(contracts).values({ projectId })
await tx.insert(deliveries).values({ projectId })
await tx.insert(acceptances).values({ projectId })
await appendTimeline(tx, { ...project, id: projectId }, {
title: '创建项目',
description: '项目已创建,进入方案阶段',
stage: PROJECT_STAGES.SOLUTION,
status: PROJECT_STATUSES.ACTIVE,
operatorName: currentUser.userName || ownerName || null,
})
})
return getProjectDetail(projectCode)
}
const buildProjectFilters = (filters = {}) => {
const conditions = []
if (filters.keyword) {
const keyword = `%${filters.keyword}%`
conditions.push(or(
like(projects.projectCode, keyword),
like(projects.name, keyword),
like(projects.schoolName, keyword),
like(projects.ownerName, keyword),
like(projects.operatorName, keyword),
))
}
const stage = toNumberOrUndefined(filters.stage)
const status = toNumberOrUndefined(filters.status)
if (stage !== undefined) conditions.push(eq(projects.stage, stage))
if (status !== undefined) conditions.push(eq(projects.status, status))
if (filters.ownerUserId) conditions.push(eq(projects.ownerUserId, filters.ownerUserId))
return conditions.length ? and(...conditions) : undefined
}
export const listProjects = async (filters = {}, options = {}) => {
const page = Math.max(parseInt(options.page, 10) || 1, 1)
const limit = Math.min(Math.max(parseInt(options.limit, 10) || 20, 1), 100)
const offset = (page - 1) * limit
const where = buildProjectFilters(filters)
const [projectRows, totalRows] = await Promise.all([
db
.select({ project: projects, productName: products.name })
.from(projects)
.leftJoin(products, eq(projects.productId, products.id))
.where(where)
.orderBy(desc(projects.createdAt))
.limit(limit)
.offset(offset),
db.select({ total: count() }).from(projects).where(where),
])
const list = projectRows.map((row) => ({
...row.project,
productName: row.productName || row.project.productName,
}))
const total = totalRows[0]?.total || 0
return {
list,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
export const getProjectByCode = async (projectCode) => getProjectByCodeWithDb(db, projectCode)
export const getProjectDetail = async (projectCode) => {
const project = await getProjectByCode(projectCode)
if (!project) return null
const [
initiationRows,
procurementRows,
contractRows,
deliveryRows,
acceptanceRows,
approvalRows,
timelineRows,
] = await Promise.all([
db.select().from(initiations).where(eq(initiations.projectId, project.id)).limit(1),
db.select().from(procurements).where(eq(procurements.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(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)),
])
return {
...project,
initiation: initiationRows[0] || {},
procurement: procurementRows[0] || {},
contract: contractRows[0] || {},
delivery: deliveryRows[0] || {},
acceptance: acceptanceRows[0] || {},
approvals: approvalRows.map(mapApproval),
timeline: timelineRows,
}
}
export const updateProject = async (projectCode, payload = {}, currentUser = {}) => {
const values = pickDefined(payload, [
'name',
'province',
'city',
'schoolName',
'departmentName',
'ownerUserId',
'ownerName',
'ownerMobile',
'ownerDingUserId',
'contactName',
'contactTitle',
'contactPhone',
'solution',
'description',
])
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) {
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
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)
}
const getOrCreatePhaseRow = async (tx, table, projectId) => {
const rows = await tx.select().from(table).where(eq(table.projectId, projectId)).limit(1)
if (rows[0]) return rows[0]
const [result] = await tx.insert(table).values({ projectId })
return { id: result.insertId, projectId }
}
const upsertPhase = async (projectCode, table, payload, fields, currentUser = {}) => {
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
const row = await getOrCreatePhaseRow(tx, table, project.id)
const values = pickDefined(payload, fields)
if (Object.keys(values).length) {
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))
}
})
return exists ? getProjectDetail(projectCode) : null
}
export const upsertInitiation = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, initiations, payload, [
'applicationFileUrl',
'argumentFileUrl',
'projectAmount',
'fundSource',
'executionPlan',
'expectedTenderTime',
], currentUser)
export const upsertProcurement = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, procurements, payload, [
'tenderOwner',
'businessParams',
'technicalParams',
'intentionPublicity',
'mainBidOwner',
'companionBidders',
'formalBidStatus',
'winningNoticeFileUrl',
'bidArchiveFileUrl',
], currentUser)
export const upsertContract = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, contracts, payload, ['contractNo', 'contractName', 'amount', 'drafter', 'archiveFileUrl'], currentUser)
export const upsertDelivery = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, deliveries, payload, [
'deliveryAddress',
'deliveryContact',
'deliveryContactPhone',
'deliveryNote',
'completedAt',
], currentUser)
export const upsertAcceptance = (projectCode, payload = {}, currentUser = {}) =>
upsertPhase(projectCode, acceptances, payload, ['acceptanceReportUrl', 'acceptanceNote'], currentUser)
export const moveProjectStage = async (projectCode, payload = {}, currentUser = {}) => {
const stage = toNumberOrUndefined(payload.toStage)
const status = PROJECT_STATUSES.ACTIVE
if (stage === undefined) {
throw httpError(400, 'toStage 不能为空')
}
let exists = false
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
exists = true
const isNoop = project.stage === stage && project.status === status
if (!isNoop) {
const matchedTransition = isManualTransitionAllowed({
fromStage: project.stage,
fromStatus: project.status,
toStage: stage,
toStatus: status,
})
if (!matchedTransition) {
throw httpError(400, '不允许的阶段流转')
}
}
await tx.update(projects).set({ stage, status, ...resolveOperator(currentUser) }).where(eq(projects.id, project.id))
await appendStageTimeline(tx, project, stage, status, payload.description || '项目阶段已更新', {
operatorName: currentUser.userName || null,
})
})
return exists ? getProjectDetail(projectCode) : null
}
export const deleteProject = async (projectCode) => {
let removedProject = null
await db.transaction(async (tx) => {
const project = await getProjectByCodeWithDb(tx, projectCode)
if (!project) return
if (!isProjectDeleteAllowed({ stage: project.stage, status: project.status })) {
throw httpError(400, '仅方案/立项阶段的进行中项目允许删除')
}
await tx.delete(projects).where(eq(projects.id, project.id))
removedProject = project
})
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 { APPROVAL_KEYS } from '../approvals/constants.js'
export const MANUAL_STAGE_TRANSITIONS = [
{
fromStage: PROJECT_STAGES.SOLUTION,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.INITIATION,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{
fromStage: PROJECT_STAGES.PROCUREMENT,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
toStage: PROJECT_STAGES.CONTRACT,
toStatus: PROJECT_STATUSES.ACTIVE,
},
{
fromStage: PROJECT_STAGES.DELIVERY,
fromStatuses: [PROJECT_STATUSES.ACTIVE],
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],
},
}
export const PROJECT_DELETE_RULE = {
stages: [PROJECT_STAGES.SOLUTION, PROJECT_STAGES.INITIATION],
statuses: [PROJECT_STATUSES.ACTIVE],
}
export const isManualTransitionAllowed = ({ fromStage, fromStatus, toStage, toStatus }) =>
MANUAL_STAGE_TRANSITIONS.some(
(item) =>
item.fromStage === fromStage &&
item.toStage === toStage &&
item.toStatus === toStatus &&
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'),
})
import { UAParser } from 'ua-parser-js' import { UAParser } from 'ua-parser-js'
import * as logsService from './logs.service.js' import * as logsService from './logs.service.js'
import logger from '../../lib/logger.js' import { success } from '../../lib/response.js'
import { success, error } from '../../lib/response.js'
// 解析 userAgent 获取设备信息 // 解析 userAgent 获取设备信息
const parseDevice = (userAgent) => { const parseDevice = (userAgent) => {
...@@ -19,112 +18,46 @@ const parseDevice = (userAgent) => { ...@@ -19,112 +18,46 @@ const parseDevice = (userAgent) => {
} }
} }
export const create = async (req, res, next) => { export const create = async (req, res) => {
try { const userAgent = req.get('user-agent')
const userAgent = req.get('user-agent') const logData = {
const logData = { ...req.body,
...req.body, ip: req.ip,
ip: req.ip, userAgent,
userAgent, device: parseDevice(userAgent),
device: parseDevice(userAgent),
}
const log = await logsService.create(logData)
success(res, log)
} catch (err) {
logger.error({ err }, 'Failed to create log')
next(err)
} }
const log = await logsService.create(logData)
success(res, log)
} }
export const createBatch = async (req, res, next) => { export const createBatch = async (req, res) => {
try { const { logs } = req.body
const { logs } = req.body const userAgent = req.get('user-agent')
if (!Array.isArray(logs) || logs.length === 0) { const device = parseDevice(userAgent)
return error(res, 'logs array is required')
}
const userAgent = req.get('user-agent')
const device = parseDevice(userAgent)
const enrichedLogs = logs.map((log) => ({
...log,
ip: req.ip,
userAgent,
device,
}))
const result = await logsService.createBatch(enrichedLogs) const enrichedLogs = logs.map((log) => ({
success(res, { inserted: result.length }) ...log,
} catch (err) { ip: req.ip,
logger.error({ err }, 'Failed to create batch logs') userAgent,
next(err) device,
} }))
}
export const query = async (req, res, next) => {
try {
const { type, level, appName, userId, startTime, endTime, keyword, page, limit } = req.query
const result = await logsService.query(
{ type, level, appName, userId, startTime, endTime, keyword },
{ page: parseInt(page) || 1, limit: parseInt(limit) || 50 }
)
success(res, result)
} catch (err) {
logger.error({ err }, 'Failed to query logs')
next(err)
}
}
export const getStats = async (req, res, next) => { const result = await logsService.createBatch(enrichedLogs)
try { success(res, { inserted: result.length })
const { appName } = req.query
const stats = await logsService.getStats({ appName })
success(res, stats)
} catch (err) {
logger.error({ err }, 'Failed to get log stats')
next(err)
}
} }
export const getUserUsage = async (req, res, next) => { export const query = async (req, res) => {
try { const { type, level, appName, userId, startTime, endTime, keyword, page, limit } = req.query
const { appName, startTime, endTime, page, limit, sortBy, sortOrder } = req.query
const result = await logsService.getUserUsage(
{ appName, startTime, endTime },
{ page: parseInt(page) || 1, limit: parseInt(limit) || 20, sortBy, sortOrder }
)
success(res, result) const result = await logsService.query(
} catch (err) { { type, level, appName, userId, startTime, endTime, keyword },
logger.error({ err }, 'Failed to get user usage stats') { page, limit },
next(err) )
} success(res, result)
} }
export const aggregateStats = async (req, res, next) => { export const getStats = async (req, res) => {
try { const { appName } = req.query
const { field, value, filters = {}, options = {} } = req.body const stats = await logsService.getStats({ appName })
success(res, stats)
if (!field) {
return error(res, 'field is required')
}
logger.info({ field, value, filters, options }, 'Aggregating stats')
const result = await logsService.aggregateStats({
field,
value,
filters,
options,
})
success(res, result)
} catch (err) {
logger.error({ err }, 'Failed to aggregate stats')
next(err)
}
} }
import { Router } from 'express' import { Router } from 'express'
import { validate } from '../../lib/validate.js'
import * as controller from './logs.controller.js' import * as controller from './logs.controller.js'
import {
createBatchBody,
createLogBody,
queryLogsQuery,
statsQuery,
} from './logs.validations.js'
const router = Router() const router = Router()
router.post('/logs', controller.create) router.post('/logs', validate({ body: createLogBody }), controller.create)
router.post('/logs/batch', controller.createBatch) router.post('/logs/batch', validate({ body: createBatchBody }), controller.createBatch)
router.get('/logs', controller.query) router.get('/logs', validate({ query: queryLogsQuery }), controller.query)
router.get('/logs/stats', controller.getStats) router.get('/logs/stats', validate({ query: statsQuery }), controller.getStats)
router.get('/logs/user-usage', controller.getUserUsage)
router.post('/logs/aggregate', controller.aggregateStats)
export default router export default router
import Log from './logs.model.js' import Log from './logs.model.js'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { getMysqlPool } from '../../lib/mysql.js'
const TEXT_API_PATTERN = '/api/volcano/api/v3/chat/completions' const TEXT_API_PATTERN = '/api/volcano/api/v3/chat/completions'
const IMAGE_API_PATTERN = '/api/volcano/api/v3/images/generations' const IMAGE_API_PATTERN = '/api/volcano/api/v3/images/generations'
...@@ -20,44 +19,6 @@ const buildAllowedApiUrlMatch = () => ({ ...@@ -20,44 +19,6 @@ const buildAllowedApiUrlMatch = () => ({
$in: ALLOWED_API_PATTERNS.map((pattern) => new RegExp(buildStrictApiRegex(pattern), 'i')), $in: ALLOWED_API_PATTERNS.map((pattern) => new RegExp(buildStrictApiRegex(pattern), 'i')),
}) })
const enrichUserUsageList = async (list) => {
if (!list.length) return list
const ssoIds = list.map((item) => item.userId).filter(Boolean)
if (!ssoIds.length) return list
const pool = getMysqlPool()
const [rows] = await pool.query(
`
SELECT
CAST(s.sso_id AS CHAR) AS userId,
s.name AS studentName,
s.mobile AS mobile,
CAST(s.organ_id AS CHAR) AS organId,
c.name AS className
FROM learning_s_edu_student s
LEFT JOIN learning_r_edu_class_student r ON r.student_id = s.id
LEFT JOIN learning_s_edu_class c ON c.id = r.class_id
WHERE s.sso_id IN (?)
`,
[ssoIds]
)
const profileMap = new Map(rows.map((row) => [row.userId, row]))
return list.map((item) => {
const profile = profileMap.get(item.userId)
return {
...item,
studentName: profile?.studentName || null,
mobile: profile?.mobile || null,
organId: profile?.organId || null,
className: profile?.className || null,
}
})
}
export const create = async (logData) => { export const create = async (logData) => {
const log = new Log(logData) const log = new Log(logData)
return log.save() return log.save()
...@@ -183,242 +144,3 @@ export const getStats = async (filters = {}) => { ...@@ -183,242 +144,3 @@ export const getStats = async (filters = {}) => {
recentLogs, recentLogs,
} }
} }
export const getUserUsage = async (filters = {}, options = {}) => {
const { page = 1, limit = 20, sortBy = 'callCount', sortOrder = 'desc' } = options
const skip = (page - 1) * limit
const allowedSortFields = ['callCount', 'textCount', 'imageCount', 'videoCount', 'digitalHumanCount', 'lastCallAt']
const finalSortField = allowedSortFields.includes(sortBy) ? sortBy : 'callCount'
const finalSortOrder = sortOrder === 'asc' ? 1 : -1
const match = {
type: 'api',
userId: { $exists: true, $nin: [null, ''] },
'api.url': buildAllowedApiUrlMatch(),
'api.statusCode': { $gte: 200, $lt: 400 },
}
if (filters.appName) match.appName = filters.appName
if (filters.startTime || filters.endTime) {
match.createdAt = {}
if (filters.startTime) match.createdAt.$gte = filters.startTime
if (filters.endTime) match.createdAt.$lte = filters.endTime
}
const [rawList, totalResult, summaryResult] = await Promise.all([
Log.aggregate([
{ $match: match },
{
$group: {
_id: '$userId',
callCount: { $sum: 1 },
textCount: {
$sum: {
$cond: [{ $regexMatch: { input: '$api.url', regex: buildStrictApiRegex(TEXT_API_PATTERN), options: 'i' } }, 1, 0],
},
},
imageCount: {
$sum: {
$cond: [{ $regexMatch: { input: '$api.url', regex: buildStrictApiRegex(IMAGE_API_PATTERN), options: 'i' } }, 1, 0],
},
},
videoCount: {
$sum: {
$cond: [
{
$regexMatch: {
input: '$api.url',
regex: buildStrictApiRegex(VIDEO_API_PATTERN),
options: 'i',
},
},
1,
0,
],
},
},
digitalHumanCount: {
$sum: {
$cond: [
{
$regexMatch: {
input: '$api.url',
regex: DIGITAL_HUMAN_API_PATTERNS.map(buildStrictApiRegex).join('|'),
options: 'i',
},
},
1,
0,
],
},
},
lastCallAt: { $max: '$createdAt' },
},
},
{ $sort: { [finalSortField]: finalSortOrder, lastCallAt: -1 } },
{ $skip: skip },
{ $limit: limit },
{
$project: {
_id: 0,
userId: '$_id',
callCount: 1,
textCount: 1,
imageCount: 1,
videoCount: 1,
digitalHumanCount: 1,
lastCallAt: 1,
},
},
]),
Log.aggregate([
{ $match: match },
{
$group: {
_id: '$userId',
},
},
{
$count: 'total',
},
]),
Log.aggregate([
{ $match: match },
{
$group: {
_id: null,
callCount: { $sum: 1 },
textCount: {
$sum: {
$cond: [{ $regexMatch: { input: '$api.url', regex: buildStrictApiRegex(TEXT_API_PATTERN), options: 'i' } }, 1, 0],
},
},
imageCount: {
$sum: {
$cond: [{ $regexMatch: { input: '$api.url', regex: buildStrictApiRegex(IMAGE_API_PATTERN), options: 'i' } }, 1, 0],
},
},
videoCount: {
$sum: {
$cond: [
{
$regexMatch: {
input: '$api.url',
regex: buildStrictApiRegex(VIDEO_API_PATTERN),
options: 'i',
},
},
1,
0,
],
},
},
digitalHumanCount: {
$sum: {
$cond: [
{
$regexMatch: {
input: '$api.url',
regex: DIGITAL_HUMAN_API_PATTERNS.map(buildStrictApiRegex).join('|'),
options: 'i',
},
},
1,
0,
],
},
},
},
},
{
$project: {
_id: 0,
callCount: 1,
textCount: 1,
imageCount: 1,
videoCount: 1,
digitalHumanCount: 1,
},
},
]),
])
const total = totalResult[0]?.total || 0
const list = await enrichUserUsageList(rawList)
const summary = summaryResult[0] || {
callCount: 0,
textCount: 0,
imageCount: 0,
videoCount: 0,
digitalHumanCount: 0,
}
return {
list,
summary,
pagination: {
page,
limit,
total,
pages: Math.ceil(total / limit),
},
}
}
/**
* 通用统计接口
* @param {object} params - 统计参数
* @param {string} params.field - 要统计的字段名称(如 'api.url', 'appName', 'userId' 等)
* @param {string} params.value - 字段包含的字符串(用于过滤,可选)
* @param {object} params.filters - 其他过滤条件
* @param {object} params.options - 统计选项
*/
export const aggregateStats = async (params = {}) => {
const { field, value, filters = {}, options = {} } = params
if (!field) {
throw new Error('field is required')
}
// 构建基础匹配条件
const match = {}
// 如果指定了字段值,添加正则匹配
if (value) {
match[field] = { $regex: value, $options: 'i' }
} else {
// 如果没有指定值,确保字段存在
match[field] = { $exists: true, $ne: null }
}
// 添加其他过滤条件
if (filters.appName) match.appName = filters.appName
if (filters.userId) match.userId = filters.userId
if (filters.type) match.type = filters.type
if (filters.level) match.level = filters.level
if (filters.startTime || filters.endTime) {
match.createdAt = {}
if (filters.startTime) match.createdAt.$gte = filters.startTime
if (filters.endTime) match.createdAt.$lte = filters.endTime
}
// 构建聚合管道
const pipeline = [
{ $match: match },
{
$group: {
_id: `$${field}`,
count: { $sum: 1 },
},
},
{
$sort: { count: -1 },
},
{
$limit: options.limit || 50,
},
]
const result = await Log.aggregate(pipeline)
return result
}
import { z } from 'zod'
const pagination = {
page: z.coerce.number().int().min(1).optional(),
limit: z.coerce.number().int().min(1).max(500).optional(),
}
export const createLogBody = z.object({}).passthrough()
export const createBatchBody = z.object({
logs: z.array(createLogBody).min(1),
})
export const queryLogsQuery = z.object({
type: z.string().optional(),
level: z.string().optional(),
appName: z.string().optional(),
userId: z.string().optional(),
startTime: z.string().optional(),
endTime: z.string().optional(),
keyword: z.string().optional(),
...pagination,
})
export const statsQuery = z.object({
appName: z.string().optional(),
})
import * as wechatService from './wechat.service.js' import * as wechatService from './wechat.service.js'
import logger from '../../lib/logger.js'
export const getSignature = async (req, res, next) => { export const getSignature = async (req, res) => {
try { const { appId, url } = req.body
const { appId, url } = req.body const ticket = await wechatService.getCachedTicket(appId)
if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } }) // 微信签名要求使用原始URL,去除hash部分
const rawUrl = (url || req.headers.referer || '').split('#')[0]
const ticket = await wechatService.getCachedTicket(appId) const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (url || req.headers.referer || '').split('#')[0] res.json({
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl) url: rawUrl,
ticket: ticket.jsapi_ticket,
res.json({ token: signature,
url: rawUrl, noncestr: ticket.noncestr,
ticket: ticket.jsapi_ticket, timestamp: ticket.timestamp,
token: signature, })
noncestr: ticket.noncestr,
timestamp: ticket.timestamp,
})
} catch (error) {
logger.error({ err: error }, 'getSignature error')
next(error)
}
} }
export const share = async (req, res, next) => { export const share = async (req, res) => {
try { const { appId } = req.body
const { appId } = req.body const ticket = await wechatService.getCachedTicket(appId)
if (!appId) return res.status(400).json({ success: false, error: { message: 'appId is required' } }) // 微信签名要求使用原始URL,去除hash部分
const rawUrl = (req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
const ticket = await wechatService.getCachedTicket(appId) res.json({ token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp })
// 微信签名要求使用原始URL,去除hash部分
const rawUrl = (req.headers.referer || '').split('#')[0]
const signature = wechatService.generateSignature(ticket.jsapi_ticket, ticket.noncestr, ticket.timestamp, rawUrl)
res.json({ token: signature, noncestr: ticket.noncestr, timestamp: ticket.timestamp })
} catch (error) {
logger.error({ err: error }, 'share error')
next(error)
}
} }
export const getInfo = async (req, res, next) => { export const getInfo = async (req, res) => {
try { const { appId, code } = req.body
const { appId, code } = req.body const userInfo = await wechatService.getUserInfo(appId, code)
if (!appId || !code) res.json(userInfo)
return res.status(400).json({ success: false, error: { message: 'appId and code are required' } })
const userInfo = await wechatService.getUserInfo(appId, code)
res.json(userInfo)
} catch (error) {
logger.error({ err: error }, 'getInfo error')
next(error)
}
} }
import { Router } from 'express' import { Router } from 'express'
import { validate } from '../../lib/validate.js'
import * as controller from './wechat.controller.js' import * as controller from './wechat.controller.js'
import { getInfoBody, getSignatureBody, shareBody } from './wechat.validations.js'
const router = Router() const router = Router()
router.post('/share/getsignature', controller.getSignature) router.post('/share/getsignature', validate({ body: getSignatureBody }), controller.getSignature)
router.post('/share/token', controller.share) router.post('/share/token', validate({ body: shareBody }), controller.share)
router.post('/getInfo', controller.getInfo) router.post('/getInfo', validate({ body: getInfoBody }), controller.getInfo)
export default router export default router
import crypto from 'crypto' import crypto from 'crypto'
import axios from 'axios' import axios from 'axios'
import config from '../../config.js' import config from '../../config.js'
import { httpError } from '../../lib/http-error.js'
let ticketCache = { jsapi_ticket: '', noncestr: '', timestamp: '', expiresAt: 0 } let ticketCache = { jsapi_ticket: '', noncestr: '', timestamp: '', expiresAt: 0 }
const CACHE_DURATION = 60 * 60 * 1000 // 1 hour const CACHE_DURATION = 60 * 60 * 1000 // 1 hour
export const getAccessToken = async (appId) => { export const getAccessToken = async (appId) => {
const secret = config.wechat.apps[appId] const secret = config.wechat.apps[appId]
if (!secret) throw new Error(`WeChat app secret not found for appId: ${appId}`) if (!secret) throw httpError(500, `WeChat app secret not found for appId: ${appId}`)
const { data } = await axios.get('https://api.weixin.qq.com/cgi-bin/token', { const { data } = await axios.get('https://api.weixin.qq.com/cgi-bin/token', {
params: { grant_type: 'client_credential', appid: appId, secret }, params: { grant_type: 'client_credential', appid: appId, secret },
}) })
if (data.errcode) throw new Error(`Failed to get access token: ${data.errmsg}`) if (data.errcode) throw httpError(500, `Failed to get access token: ${data.errmsg}`)
return data.access_token return data.access_token
} }
...@@ -23,7 +24,7 @@ export const getJsapiTicket = async (appId) => { ...@@ -23,7 +24,7 @@ export const getJsapiTicket = async (appId) => {
params: { type: 'jsapi', access_token: accessToken }, params: { type: 'jsapi', access_token: accessToken },
}) })
if (data.errcode !== 0) throw new Error(`Failed to get JSAPI ticket: ${data.errmsg}`) if (data.errcode !== 0) throw httpError(500, `Failed to get JSAPI ticket: ${data.errmsg}`)
return data.ticket return data.ticket
} }
...@@ -48,18 +49,18 @@ export const generateSignature = (ticket, noncestr, timestamp, url) => { ...@@ -48,18 +49,18 @@ export const generateSignature = (ticket, noncestr, timestamp, url) => {
export const getUserInfo = async (appId, code) => { export const getUserInfo = async (appId, code) => {
const secret = config.wechat.apps[appId] const secret = config.wechat.apps[appId]
if (!secret) throw new Error(`WeChat app secret not found for appId: ${appId}`) if (!secret) throw httpError(500, `WeChat app secret not found for appId: ${appId}`)
const { data: tokenData } = await axios.get('https://api.weixin.qq.com/sns/oauth2/access_token', { const { data: tokenData } = await axios.get('https://api.weixin.qq.com/sns/oauth2/access_token', {
params: { appid: appId, secret, code, grant_type: 'authorization_code' }, params: { appid: appId, secret, code, grant_type: 'authorization_code' },
}) })
if (tokenData.errcode) throw new Error(`Failed to get access token: ${tokenData.errmsg}`) if (tokenData.errcode) throw httpError(500, `Failed to get access token: ${tokenData.errmsg}`)
const { data: userInfo } = await axios.get('https://api.weixin.qq.com/sns/userinfo', { const { data: userInfo } = await axios.get('https://api.weixin.qq.com/sns/userinfo', {
params: { access_token: tokenData.access_token, openid: tokenData.openid, lang: 'zh_CN' }, params: { access_token: tokenData.access_token, openid: tokenData.openid, lang: 'zh_CN' },
}) })
if (userInfo.errcode) throw new Error(`Failed to get user info: ${userInfo.errmsg}`) if (userInfo.errcode) throw httpError(500, `Failed to get user info: ${userInfo.errmsg}`)
return userInfo return userInfo
} }
import { z } from 'zod'
export const getSignatureBody = z.object({
appId: z.string().min(1),
url: z.string().optional(),
})
export const shareBody = z.object({
appId: z.string().min(1),
})
export const getInfoBody = z.object({
appId: z.string().min(1),
code: z.string().min(1),
})
import * as service from './wx-chart.service.js' import * as service from './wx-chart.service.js'
import logger from '../../lib/logger.js'
export const get = async (req, res, next) => { export const get = async (req, res) => {
try { const key = req.params.key
const key = req.baseUrl.replace(/\/get\/wx-chart\//gi, '') if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
const value = await service.getValue(key) const value = await service.getValue(key)
res.json({ success: true, data: { code: value } }) res.json({ success: true, data: { code: value } })
} catch (error) {
logger.error({ err: error }, 'wx-chart get error')
next(error)
}
} }
export const set = async (req, res, next) => { export const set = async (req, res) => {
try { const key = req.params.key
const key = req.baseUrl.replace(/\/set\/wx-chart\//gi, '') if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
if (!key) return res.status(400).json({ success: false, error: { message: 'Key is required' } })
const data = await service.setValue(key, req.query.val || 0) const data = await service.setValue(key, req.query.val || 0)
res.json({ success: true, data }) res.json({ success: true, data })
} catch (error) {
logger.error({ err: error }, 'wx-chart set error')
next(error)
}
} }
...@@ -3,7 +3,7 @@ import * as controller from './wx-chart.controller.js' ...@@ -3,7 +3,7 @@ import * as controller from './wx-chart.controller.js'
const router = Router() const router = Router()
router.use('/get/wx-chart/*', controller.get) router.get('/get/wx-chart/{*key}', controller.get)
router.use('/set/wx-chart/*', controller.set) router.get('/set/wx-chart/{*key}', controller.set)
export default router export default router
import app from './src/app.js'
console.log('app loaded OK')
Markdown 格式
0% 或
您添加了 0 人 到此讨论。请谨慎行事。
请先完成此评论的编辑!
请 注册 或者 后发表评论