Parcourir la Source

增加与ai大模型对话

wwh il y a 2 semaines
Parent
commit
dd50013d75
17 fichiers modifiés avec 948 ajouts et 53 suppressions
  1. 29 26
      doc/灵活用工-mysql.sql
  2. 30 23
      doc/灵活用工.sql
  3. 1 1
      huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mcp/McpDraftController.java
  4. 4 2
      huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/DraftController.java
  5. 41 0
      huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/publicapi/RegistrationQrController.java
  6. 13 0
      huimv-employment/fe-api/src/main/resources/application.yml
  7. 211 0
      huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeRegistrationBatch.java
  8. 9 0
      huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeRegistrationBatchMapper.java
  9. 9 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/config/RegistrationQrConfiguration.java
  10. 109 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/config/RegistrationQrProperties.java
  11. 36 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/DraftService.java
  12. 10 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/McpDraftService.java
  13. 48 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftConfirmResponse.java
  14. 15 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftDetailResponse.java
  15. 36 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/McpDraftSubmitResponse.java
  16. 199 0
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/registration/RegistrationBatchService.java
  17. 148 1
      huimv-employment/fe-service/src/main/java/com/huimv/employment/service/wechat/WeChatMiniAppService.java

+ 29 - 26
doc/灵活用工-mysql.sql

@@ -402,8 +402,8 @@ DROP TABLE IF EXISTS `fe_registration_batch`;
402 402
 CREATE TABLE `fe_registration_batch` (
403 403
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。订单进入登记步骤时由后端创建。非空。',
404 404
   `batch_no` VARCHAR(32)     NOT NULL COMMENT '登记批次业务编号。示例:BATCH-001。非空;唯一索引 uk_fe_batch_no。',
405
-  `order_id` BIGINT          NOT NULL COMMENT '关联用工订单,外键 REFERENCES fe_employment_order(id)。非空;索引 idx_fe_batch_order。',
406
-  `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业,外键 REFERENCES fe_enterprise(id)。冗余便于按企业查批次。非空。',
405
+  `order_id` BIGINT          NOT NULL COMMENT '关联用工订单 fe_employment_order.id(逻辑关联,无数据库外键)。非空;索引 idx_fe_batch_order。',
406
+  `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业 fe_enterprise.id(逻辑关联,无数据库外键)。冗余便于按企业查批次。非空。',
407 407
   `expected_count` int         NOT NULL COMMENT '本批次预期登记人数,通常等于订单 worker_count。非空。',
408 408
   `registered_count` int         NOT NULL DEFAULT 0 COMMENT '已登记总数(含进行中),冗余统计。非空,默认 0。',
409 409
   `pending_count` int         NOT NULL DEFAULT 0 COMMENT '尚未开始登记人数。非空,默认 0。',
@@ -423,9 +423,8 @@ CREATE TABLE `fe_registration_batch` (
423 423
   UNIQUE KEY `uk_fe_batch_no` (`batch_no`),
424 424
   UNIQUE KEY `uk_fe_batch_token` (`qr_token`),
425 425
   KEY `idx_fe_batch_order` (`order_id`),
426
-  CONSTRAINT `fk_fe_registration_batch_order_id` FOREIGN KEY (`order_id`) REFERENCES `fe_employment_order` (`id`),
427
-  CONSTRAINT `fk_fe_registration_batch_enterprise_id` FOREIGN KEY (`enterprise_id`) REFERENCES `fe_enterprise` (`id`)
428
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='临时工扫码登记批次表,草稿确认方案后生成,含 H5/小程序登记链接、二维码图片与有效期。企业主在进度页查看登记统计;一对多关联 fe_worker_registration。';
426
+  KEY `idx_fe_batch_enterprise` (`enterprise_id`)
427
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='临时工扫码登记批次表,草稿确认方案后生成,含 H5/小程序登记链接、二维码图片与有效期。企业主在进度页查看登记统计;一对多关联 fe_worker_registration。关联列暂不建数据库外键。';
429 428
 
430 429
 DROP TABLE IF EXISTS `fe_order_workflow_log`;
431 430
 CREATE TABLE `fe_order_workflow_log` (
@@ -475,7 +474,7 @@ CREATE TABLE `fe_worker` (
475 474
 DROP TABLE IF EXISTS `fe_worker_registration`;
476 475
 CREATE TABLE `fe_worker_registration` (
477 476
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。临时工打开登记页或提交时创建。非空。',
478
-  `batch_id` BIGINT          NOT NULL COMMENT '所属登记批次,外键 REFERENCES fe_registration_batch(id)。非空;索引 idx_fe_worker_reg_batch。',
477
+  `batch_id` BIGINT          NOT NULL COMMENT '所属登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。非空;索引 idx_fe_worker_reg_batch。',
479 478
   `order_id` BIGINT          NOT NULL COMMENT '关联订单,外键 REFERENCES fe_employment_order(id)。冗余便于按订单查登记列表。非空;索引 idx_fe_worker_reg_order。',
480 479
   `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业,外键 REFERENCES fe_enterprise(id)。冗余。非空。',
481 480
   `worker_id` BIGINT COMMENT '关联临时工档案,外键 REFERENCES fe_worker(id)。提交成功且匹配/创建档案后绑定。可空(登记进行中)。',
@@ -498,12 +497,11 @@ CREATE TABLE `fe_worker_registration` (
498 497
   PRIMARY KEY (`id`),
499 498
   KEY `idx_fe_worker_reg_batch` (`batch_id`, `reg_status`),
500 499
   KEY `idx_fe_worker_reg_order` (`order_id`),
501
-  CONSTRAINT `fk_fe_worker_registration_batch_id` FOREIGN KEY (`batch_id`) REFERENCES `fe_registration_batch` (`id`),
502 500
   CONSTRAINT `fk_fe_worker_registration_order_id` FOREIGN KEY (`order_id`) REFERENCES `fe_employment_order` (`id`),
503 501
   CONSTRAINT `fk_fe_worker_registration_enterprise_id` FOREIGN KEY (`enterprise_id`) REFERENCES `fe_enterprise` (`id`),
504 502
   CONSTRAINT `fk_fe_worker_registration_worker_id` FOREIGN KEY (`worker_id`) REFERENCES `fe_worker` (`id`),
505 503
   CONSTRAINT `fk_fe_worker_registration_reviewed_by` FOREIGN KEY (`reviewed_by`) REFERENCES `fe_user` (`id`)
506
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批次登记记录表,记录每个临时工在某一 fe_registration_batch 下的一次登记全过程。reg_status 驱动进度页统计、催办与审核;关联 fe_worker 档案,敏感证件信息加密存储。';
504
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='批次登记记录表,记录每个临时工在某一 fe_registration_batch 下的一次登记全过程。reg_status 驱动进度页统计、催办与审核;关联 fe_worker 档案,敏感证件信息加密存储。batch_id 暂无数据库外键。';
507 505
 
508 506
 DROP TABLE IF EXISTS `fe_worker_consent`;
509 507
 CREATE TABLE `fe_worker_consent` (
@@ -557,7 +555,7 @@ CREATE TABLE `fe_settlement_order` (
557 555
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。结算引擎生成结算单时创建。非空。',
558 556
   `settlement_no` VARCHAR(32)     NOT NULL COMMENT '结算单业务编号。示例:STL20260707001。非空;唯一索引 uk_fe_settlement_no。',
559 557
   `order_id` BIGINT          NOT NULL COMMENT '关联用工订单,外键 REFERENCES fe_employment_order(id)。非空。',
560
-  `batch_id` BIGINT COMMENT '关联登记批次,外键 REFERENCES fe_registration_batch(id)。可空;多批次订单可只关联主批次。',
558
+  `batch_id` BIGINT COMMENT '关联登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。可空;多批次订单可只关联主批次。',
561 559
   `enterprise_id` BIGINT          NOT NULL COMMENT '所属企业,外键 REFERENCES fe_enterprise(id)。非空;索引 idx_fe_settlement_ent。',
562 560
   `worker_count` int         NOT NULL COMMENT '本次结算涵盖人数。非空。',
563 561
   `gross_amount` decimal(14,2)   NOT NULL DEFAULT 0 COMMENT '应发总额(元),税前劳务合计。非空,默认 0。',
@@ -578,11 +576,11 @@ CREATE TABLE `fe_settlement_order` (
578 576
   PRIMARY KEY (`id`),
579 577
   UNIQUE KEY `uk_fe_settlement_no` (`settlement_no`),
580 578
   KEY `idx_fe_settlement_ent` (`enterprise_id`, `settlement_status`),
579
+  KEY `idx_fe_settlement_batch` (`batch_id`),
581 580
   CONSTRAINT `fk_fe_settlement_order_order_id` FOREIGN KEY (`order_id`) REFERENCES `fe_employment_order` (`id`),
582
-  CONSTRAINT `fk_fe_settlement_order_batch_id` FOREIGN KEY (`batch_id`) REFERENCES `fe_registration_batch` (`id`),
583 581
   CONSTRAINT `fk_fe_settlement_order_enterprise_id` FOREIGN KEY (`enterprise_id`) REFERENCES `fe_enterprise` (`id`),
584 582
   CONSTRAINT `fk_fe_settlement_order_confirmed_by` FOREIGN KEY (`confirmed_by`) REFERENCES `fe_user` (`id`)
585
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算确认单主表,用工结束后按考勤或约定生成,展示应发/扣款/实发、服务费、开票额与企业总支出。企业确认后进入支付环节;一对多关联 fe_settlement_item、fe_settlement_fee、fe_payment_record。';
583
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='结算确认单主表,用工结束后按考勤或约定生成,展示应发/扣款/实发、服务费、开票额与企业总支出。企业确认后进入支付环节;一对多关联 fe_settlement_item、fe_settlement_fee、fe_payment_record。batch_id 暂无数据库外键。';
586 584
 
587 585
 DROP TABLE IF EXISTS `fe_settlement_item`;
588 586
 CREATE TABLE `fe_settlement_item` (
@@ -649,7 +647,7 @@ CREATE TABLE `fe_payment_record` (
649 647
 DROP TABLE IF EXISTS `fe_attendance_batch`;
650 648
 CREATE TABLE `fe_attendance_batch` (
651 649
   `id` bigint NOT NULL AUTO_INCREMENT COMMENT '表主键,自增。每日定时任务或企业主手动生成日报时创建。非空。',
652
-  `batch_id` BIGINT          NOT NULL COMMENT '关联登记批次,外键 REFERENCES fe_registration_batch(id)。非空;与 attendance_date 组成唯一索引 uk_fe_attendance_batch。',
650
+  `batch_id` BIGINT          NOT NULL COMMENT '关联登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。非空;与 attendance_date 组成唯一索引 uk_fe_attendance_batch。',
653 651
   `order_id` BIGINT          NOT NULL COMMENT '关联订单,外键 REFERENCES fe_employment_order(id)。冗余便于订单维度查考勤。非空。',
654 652
   `attendance_date` DATE            NOT NULL COMMENT '考勤统计日期,DATE 类型。示例:2026-08-15。非空。',
655 653
   `total_workers` int         NOT NULL DEFAULT 0 COMMENT '当日应考勤人数。非空,默认 0。',
@@ -666,10 +664,9 @@ CREATE TABLE `fe_attendance_batch` (
666 664
   `update_time` datetime     NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '考勤数据变更时更新。非空。',
667 665
   PRIMARY KEY (`id`),
668 666
   UNIQUE KEY `uk_fe_attendance_batch` (`batch_id`, `attendance_date`),
669
-  CONSTRAINT `fk_fe_attendance_batch_batch_id` FOREIGN KEY (`batch_id`) REFERENCES `fe_registration_batch` (`id`),
670 667
   CONSTRAINT `fk_fe_attendance_batch_order_id` FOREIGN KEY (`order_id`) REFERENCES `fe_employment_order` (`id`),
671 668
   CONSTRAINT `fk_fe_attendance_batch_confirmed_by` FOREIGN KEY (`confirmed_by`) REFERENCES `fe_user` (`id`)
672
-) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤日报批次表(二期功能),按登记批次与日期汇总当日应考勤人数、出勤/加班/请假统计及预估费用。一期 MVP 可不实现业务逻辑,表结构先行预留;关联 fe_registration_batch、fe_employment_order。';
669
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='考勤日报批次表(二期功能),按登记批次与日期汇总当日应考勤人数、出勤/加班/请假统计及预估费用。一期 MVP 可不实现业务逻辑,表结构先行预留;batch_id 暂无数据库外键。';
673 670
 
674 671
 DROP TABLE IF EXISTS `fe_attendance_record`;
675 672
 CREATE TABLE `fe_attendance_record` (
@@ -814,21 +811,27 @@ CROSS JOIN (
814 811
 ) s
815 812
 WHERE wd.`workflow_code` = 'employment_compliance' AND wd.`version` = 1;
816 813
 
817
-INSERT INTO `fe_service_plan` (`plan_code`, `plan_name`, `plan_type`, `description`, `service_fee_rate`, `insurance_level`, `compliance_tags`, `sort_order`, `status`)
814
+INSERT INTO `fe_service_plan` (`plan_code`, `plan_name`, `plan_type`, `description`, `service_fee_rate`, `insurance_level`, `tax_mode`, `compliance_tags`, `sort_order`, `status`)
818 815
 VALUES
819
-  ('current', '当前处理方式', 'traditional', '企业现有处理方式估算', NULL, NULL, '[]', 0, 'active'),
820
-  ('plan_1', '合规优先 (A)', 'flexible', '测试方案一:合规优先', 0.0800, 'premium', '["业务真实","资金合规","个税申报"]', 1, 'test_only'),
821
-  ('plan_2', '传统用工 (B)', 'traditional', '测试方案二:传统用工', NULL, NULL, '[]', 2, 'test_only'),
822
-  ('plan_3', '成本优化 (C)', 'flexible', '测试方案三:成本优化', 0.0600, 'basic', '["业务真实","资金合规"]', 3, 'test_only');
823
-
824
-INSERT INTO `fe_service_plan_fee_rule` (`plan_id`, `fee_code`, `fee_name`, `calc_type`, `default_rate`, `sort_order`)
825
-SELECT p.`id`, f.`fee_code`, f.`fee_name`, f.`calc_type`, f.`default_rate`, f.`sort_order`
816
+  ('current', '当前处理方式', 'traditional', '企业现有处理方式估算', NULL, NULL, NULL, '[]', 0, 'active'),
817
+  ('SCHEME_A', '灵活用工模式(推荐)', 'flexible', '灵活用工:企业支付劳务费 + 平台服务费,劳动者到手无需预扣个税(示例)', 0.0200, 'standard', 'platform_withhold', '["合规","低税负"]', 1, 'active'),
818
+  ('SCHEME_B', '劳务报酬模式', 'traditional', '劳务报酬:企业支付劳务费,按劳务报酬预扣个税(示例:3000 元对应税 440 元)', NULL, NULL, 'labor_remuneration', '["传统"]', 2, 'active'),
819
+  ('plan_1', '合规优先 (A)', 'flexible', '测试方案一:合规优先', 0.0800, 'premium', NULL, '["业务真实","资金合规","个税申报"]', 11, 'test_only'),
820
+  ('plan_2', '传统用工 (B)', 'traditional', '测试方案二:传统用工', NULL, NULL, NULL, '[]', 12, 'test_only'),
821
+  ('plan_3', '成本优化 (C)', 'flexible', '测试方案三:成本优化', 0.0600, 'basic', NULL, '["业务真实","资金合规"]', 13, 'test_only');
822
+
823
+INSERT INTO `fe_service_plan_fee_rule` (`plan_id`, `fee_code`, `fee_name`, `calc_type`, `calc_expression`, `default_rate`, `sort_order`)
824
+SELECT p.`id`, f.`fee_code`, f.`fee_name`, f.`calc_type`, f.`calc_expression`, f.`default_rate`, f.`sort_order`
826 825
 FROM `fe_service_plan` p
827 826
 INNER JOIN (
828
-  SELECT 'plan_1' AS plan_code, 'labor_fee' AS fee_code, '劳务费' AS fee_name, 'formula' AS calc_type, NULL AS default_rate, 1 AS sort_order
829
-  UNION ALL SELECT 'plan_1', 'service_fee', '服务费', 'rate', 0.0800, 2
830
-  UNION ALL SELECT 'plan_1', 'insurance', '保险', 'fixed', NULL, 3
831
-  UNION ALL SELECT 'plan_1', 'tax_withhold', '税费代征', 'formula', NULL, 4
827
+  SELECT 'SCHEME_A' AS plan_code, 'labor_fee' AS fee_code, '劳务费' AS fee_name, 'formula' AS calc_type, 'gross' AS calc_expression, NULL AS default_rate, 1 AS sort_order
828
+  UNION ALL SELECT 'SCHEME_A', 'service_fee', '服务费', 'rate', 'gross * service_fee_rate', 0.0200, 2
829
+  UNION ALL SELECT 'SCHEME_B', 'labor_fee', '劳务费', 'formula', 'gross', NULL, 1
830
+  UNION ALL SELECT 'SCHEME_B', 'tax_withhold', '个税预扣', 'formula', 'gross*440/3000', NULL, 2
831
+  UNION ALL SELECT 'plan_1', 'labor_fee', '劳务费', 'formula', NULL, NULL, 1
832
+  UNION ALL SELECT 'plan_1', 'service_fee', '服务费', 'rate', NULL, 0.0800, 2
833
+  UNION ALL SELECT 'plan_1', 'insurance', '保险', 'fixed', NULL, NULL, 3
834
+  UNION ALL SELECT 'plan_1', 'tax_withhold', '税费代征', 'formula', NULL, NULL, 4
832 835
 ) f ON p.`plan_code` = f.`plan_code`;
833 836
 
834 837
 SET FOREIGN_KEY_CHECKS = 1;

+ 30 - 23
doc/灵活用工.sql

@@ -415,8 +415,8 @@ CREATE INDEX idx_fe_order_ent ON fe_employment_order (enterprise_id, order_statu
415 415
 CREATE TABLE fe_registration_batch (
416 416
     id                  BIGSERIAL       PRIMARY KEY,
417 417
     batch_no            VARCHAR(32)     NOT NULL,                   -- BATCH-001
418
-    order_id            BIGINT          NOT NULL REFERENCES fe_employment_order(id),
419
-    enterprise_id       BIGINT          NOT NULL REFERENCES fe_enterprise(id),
418
+    order_id            BIGINT          NOT NULL,                   -- 逻辑关联 fe_employment_order.id(无 FK)
419
+    enterprise_id       BIGINT          NOT NULL,                   -- 逻辑关联 fe_enterprise.id(无 FK)
420 420
     expected_count      INTEGER         NOT NULL,
421 421
     registered_count    INTEGER         NOT NULL DEFAULT 0,
422 422
     pending_count       INTEGER         NOT NULL DEFAULT 0,
@@ -436,6 +436,7 @@ CREATE TABLE fe_registration_batch (
436 436
 CREATE UNIQUE INDEX uk_fe_batch_no ON fe_registration_batch (batch_no);
437 437
 CREATE UNIQUE INDEX uk_fe_batch_token ON fe_registration_batch (qr_token) WHERE qr_token IS NOT NULL;
438 438
 CREATE INDEX idx_fe_batch_order ON fe_registration_batch (order_id);
439
+CREATE INDEX idx_fe_batch_enterprise ON fe_registration_batch (enterprise_id);
439 440
 
440 441
 
441 442
 -- 订单流程状态变更日志(合规状态机留痕)
@@ -489,7 +490,7 @@ CREATE INDEX idx_fe_worker_mobile ON fe_worker (mobile_mask);
489 490
 -- 批次登记记录
490 491
 CREATE TABLE fe_worker_registration (
491 492
     id                  BIGSERIAL       PRIMARY KEY,
492
-    batch_id            BIGINT          NOT NULL REFERENCES fe_registration_batch(id),
493
+    batch_id            BIGINT          NOT NULL,                   -- 逻辑关联 fe_registration_batch.id(无 FK)
493 494
     order_id            BIGINT          NOT NULL REFERENCES fe_employment_order(id),
494 495
     enterprise_id       BIGINT          NOT NULL REFERENCES fe_enterprise(id),
495 496
     worker_id           BIGINT          REFERENCES fe_worker(id),
@@ -569,7 +570,7 @@ CREATE TABLE fe_settlement_order (
569 570
     id                  BIGSERIAL       PRIMARY KEY,
570 571
     settlement_no       VARCHAR(32)     NOT NULL,
571 572
     order_id            BIGINT          NOT NULL REFERENCES fe_employment_order(id),
572
-    batch_id            BIGINT          REFERENCES fe_registration_batch(id),
573
+    batch_id            BIGINT,                                     -- 逻辑关联 fe_registration_batch.id(无 FK)
573 574
     enterprise_id       BIGINT          NOT NULL REFERENCES fe_enterprise(id),
574 575
     worker_count        INTEGER         NOT NULL,
575 576
     gross_amount        NUMERIC(14,2)   NOT NULL DEFAULT 0,         -- 应发总额
@@ -654,7 +655,7 @@ CREATE INDEX idx_fe_payment_settlement ON fe_payment_record (settlement_id);
654 655
 
655 656
 CREATE TABLE fe_attendance_batch (
656 657
     id              BIGSERIAL       PRIMARY KEY,
657
-    batch_id        BIGINT          NOT NULL REFERENCES fe_registration_batch(id),
658
+    batch_id        BIGINT          NOT NULL,                       -- 逻辑关联 fe_registration_batch.id(无 FK)
658 659
     order_id        BIGINT          NOT NULL REFERENCES fe_employment_order(id),
659 660
     attendance_date DATE            NOT NULL,
660 661
     total_workers   INTEGER         NOT NULL DEFAULT 0,
@@ -1075,8 +1076,8 @@ COMMENT ON COLUMN fe_employment_order.del_flag IS '逻辑删除标记。枚举
1075 1076
 COMMENT ON TABLE fe_registration_batch IS '临时工扫码登记批次表,草稿确认方案后生成,含 H5/小程序登记链接、二维码图片与有效期。企业主在进度页查看登记统计;一对多关联 fe_worker_registration。';
1076 1077
 COMMENT ON COLUMN fe_registration_batch.id IS '表主键,自增。订单进入登记步骤时由后端创建。非空。';
1077 1078
 COMMENT ON COLUMN fe_registration_batch.batch_no IS '登记批次业务编号。示例:BATCH-001。非空;唯一索引 uk_fe_batch_no。';
1078
-COMMENT ON COLUMN fe_registration_batch.order_id IS '关联用工订单,外键 REFERENCES fe_employment_order(id)。非空;索引 idx_fe_batch_order。';
1079
-COMMENT ON COLUMN fe_registration_batch.enterprise_id IS '所属企业,外键 REFERENCES fe_enterprise(id)。冗余便于按企业查批次。非空。';
1079
+COMMENT ON COLUMN fe_registration_batch.order_id IS '关联用工订单 fe_employment_order.id(逻辑关联,无数据库外键)。非空;索引 idx_fe_batch_order。';
1080
+COMMENT ON COLUMN fe_registration_batch.enterprise_id IS '所属企业 fe_enterprise.id(逻辑关联,无数据库外键)。冗余便于按企业查批次。非空。';
1080 1081
 COMMENT ON COLUMN fe_registration_batch.expected_count IS '本批次预期登记人数,通常等于订单 worker_count。非空。';
1081 1082
 COMMENT ON COLUMN fe_registration_batch.registered_count IS '已登记总数(含进行中),冗余统计。非空,默认 0。';
1082 1083
 COMMENT ON COLUMN fe_registration_batch.pending_count IS '尚未开始登记人数。非空,默认 0。';
@@ -1127,7 +1128,7 @@ COMMENT ON COLUMN fe_worker.del_flag IS '逻辑删除标记。枚举:0=正常
1127 1128
 
1128 1129
 COMMENT ON TABLE fe_worker_registration IS '批次登记记录表,记录每个临时工在某一 fe_registration_batch 下的一次登记全过程。reg_status 驱动进度页统计、催办与审核;关联 fe_worker 档案,敏感证件信息加密存储。';
1129 1130
 COMMENT ON COLUMN fe_worker_registration.id IS '表主键,自增。临时工打开登记页或提交时创建。非空。';
1130
-COMMENT ON COLUMN fe_worker_registration.batch_id IS '所属登记批次,外键 REFERENCES fe_registration_batch(id)。非空;索引 idx_fe_worker_reg_batch。';
1131
+COMMENT ON COLUMN fe_worker_registration.batch_id IS '所属登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。非空;索引 idx_fe_worker_reg_batch。';
1131 1132
 COMMENT ON COLUMN fe_worker_registration.order_id IS '关联订单,外键 REFERENCES fe_employment_order(id)。冗余便于按订单查登记列表。非空;索引 idx_fe_worker_reg_order。';
1132 1133
 COMMENT ON COLUMN fe_worker_registration.enterprise_id IS '所属企业,外键 REFERENCES fe_enterprise(id)。冗余。非空。';
1133 1134
 COMMENT ON COLUMN fe_worker_registration.worker_id IS '关联临时工档案,外键 REFERENCES fe_worker(id)。提交成功且匹配/创建档案后绑定。可空(登记进行中)。';
@@ -1184,7 +1185,7 @@ COMMENT ON TABLE fe_settlement_order IS '结算确认单主表,用工结束后
1184 1185
 COMMENT ON COLUMN fe_settlement_order.id IS '表主键,自增。结算引擎生成结算单时创建。非空。';
1185 1186
 COMMENT ON COLUMN fe_settlement_order.settlement_no IS '结算单业务编号。示例:STL20260707001。非空;唯一索引 uk_fe_settlement_no。';
1186 1187
 COMMENT ON COLUMN fe_settlement_order.order_id IS '关联用工订单,外键 REFERENCES fe_employment_order(id)。非空。';
1187
-COMMENT ON COLUMN fe_settlement_order.batch_id IS '关联登记批次,外键 REFERENCES fe_registration_batch(id)。可空;多批次订单可只关联主批次。';
1188
+COMMENT ON COLUMN fe_settlement_order.batch_id IS '关联登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。可空;多批次订单可只关联主批次。';
1188 1189
 COMMENT ON COLUMN fe_settlement_order.enterprise_id IS '所属企业,外键 REFERENCES fe_enterprise(id)。非空;索引 idx_fe_settlement_ent。';
1189 1190
 COMMENT ON COLUMN fe_settlement_order.worker_count IS '本次结算涵盖人数。非空。';
1190 1191
 COMMENT ON COLUMN fe_settlement_order.gross_amount IS '应发总额(元),税前劳务合计。非空,默认 0。';
@@ -1247,7 +1248,7 @@ COMMENT ON COLUMN fe_payment_record.update_time IS '状态变更时更新。非
1247 1248
 
1248 1249
 COMMENT ON TABLE fe_attendance_batch IS '考勤日报批次表(二期功能),按登记批次与日期汇总当日应考勤人数、出勤/加班/请假统计及预估费用。一期 MVP 可不实现业务逻辑,表结构先行预留;关联 fe_registration_batch、fe_employment_order。';
1249 1250
 COMMENT ON COLUMN fe_attendance_batch.id IS '表主键,自增。每日定时任务或企业主手动生成日报时创建。非空。';
1250
-COMMENT ON COLUMN fe_attendance_batch.batch_id IS '关联登记批次,外键 REFERENCES fe_registration_batch(id)。非空;与 attendance_date 组成唯一索引 uk_fe_attendance_batch。';
1251
+COMMENT ON COLUMN fe_attendance_batch.batch_id IS '关联登记批次 fe_registration_batch.id(逻辑关联,无数据库外键)。非空;与 attendance_date 组成唯一索引 uk_fe_attendance_batch。';
1251 1252
 COMMENT ON COLUMN fe_attendance_batch.order_id IS '关联订单,外键 REFERENCES fe_employment_order(id)。冗余便于订单维度查考勤。非空。';
1252 1253
 COMMENT ON COLUMN fe_attendance_batch.attendance_date IS '考勤统计日期,DATE 类型。示例:2026-08-15。非空。';
1253 1254
 COMMENT ON COLUMN fe_attendance_batch.total_workers IS '当日应考勤人数。非空,默认 0。';
@@ -1373,22 +1374,28 @@ FROM fe_workflow_definition wd,
1373 1374
 ) AS s(step_code, step_name, step_order)
1374 1375
 WHERE wd.workflow_code = 'employment_compliance' AND wd.version = 1;
1375 1376
 
1376
-INSERT INTO fe_service_plan (plan_code, plan_name, plan_type, description, service_fee_rate, insurance_level, compliance_tags, sort_order, status)
1377
+INSERT INTO fe_service_plan (plan_code, plan_name, plan_type, description, service_fee_rate, insurance_level, tax_mode, compliance_tags, sort_order, status)
1377 1378
 VALUES
1378
-    ('current', '当前处理方式',   'traditional', '企业现有处理方式估算', NULL,  NULL,      '[]', 0, 'active'),
1379
-    ('plan_1',  '合规优先 (A)',   'flexible',    '测试方案一:合规优先', 0.08, 'premium', '["业务真实","资金合规","个税申报"]', 1, 'test_only'),
1380
-    ('plan_2',  '传统用工 (B)',   'traditional', '测试方案二:传统用工', NULL,  NULL,      '[]', 2, 'test_only'),
1381
-    ('plan_3',  '成本优化 (C)',   'flexible',    '测试方案三:成本优化', 0.06, 'basic',   '["业务真实","资金合规"]', 3, 'test_only');
1382
-
1383
-INSERT INTO fe_service_plan_fee_rule (plan_id, fee_code, fee_name, calc_type, default_rate, sort_order)
1384
-SELECT p.id, f.fee_code, f.fee_name, f.calc_type, f.default_rate, f.sort_order
1379
+    ('current',  '当前处理方式',         'traditional', '企业现有处理方式估算', NULL,  NULL,      NULL,                   '[]', 0, 'active'),
1380
+    ('SCHEME_A', '灵活用工模式(推荐)', 'flexible',    '灵活用工:企业支付劳务费 + 平台服务费,劳动者到手无需预扣个税(示例)', 0.02, 'standard', 'platform_withhold', '["合规","低税负"]', 1, 'active'),
1381
+    ('SCHEME_B', '劳务报酬模式',         'traditional', '劳务报酬:企业支付劳务费,按劳务报酬预扣个税(示例:3000 元对应税 440 元)', NULL, NULL, 'labor_remuneration', '["传统"]', 2, 'active'),
1382
+    ('plan_1',   '合规优先 (A)',         'flexible',    '测试方案一:合规优先', 0.08, 'premium', NULL,                   '["业务真实","资金合规","个税申报"]', 11, 'test_only'),
1383
+    ('plan_2',   '传统用工 (B)',         'traditional', '测试方案二:传统用工', NULL,  NULL,      NULL,                   '[]', 12, 'test_only'),
1384
+    ('plan_3',   '成本优化 (C)',         'flexible',    '测试方案三:成本优化', 0.06, 'basic',   NULL,                   '["业务真实","资金合规"]', 13, 'test_only');
1385
+
1386
+INSERT INTO fe_service_plan_fee_rule (plan_id, fee_code, fee_name, calc_type, calc_expression, default_rate, sort_order)
1387
+SELECT p.id, f.fee_code, f.fee_name, f.calc_type, f.calc_expression, f.default_rate, f.sort_order
1385 1388
 FROM fe_service_plan p
1386 1389
 JOIN (VALUES
1387
-    ('plan_1', 'labor_fee',    '劳务费',   'formula', NULL,  1),
1388
-    ('plan_1', 'service_fee',  '服务费',   'rate',    0.08,  2),
1389
-    ('plan_1', 'insurance',    '保险',     'fixed',   NULL,  3),
1390
-    ('plan_1', 'tax_withhold', '税费代征', 'formula', NULL,  4)
1391
-) AS f(plan_code, fee_code, fee_name, calc_type, default_rate, sort_order)
1390
+    ('SCHEME_A', 'labor_fee',     '劳务费',   'formula', 'gross',                  NULL,  1),
1391
+    ('SCHEME_A', 'service_fee',   '服务费',   'rate',    'gross * service_fee_rate', 0.02, 2),
1392
+    ('SCHEME_B', 'labor_fee',     '劳务费',   'formula', 'gross',                  NULL,  1),
1393
+    ('SCHEME_B', 'tax_withhold',  '个税预扣', 'formula', 'gross*440/3000',         NULL,  2),
1394
+    ('plan_1',   'labor_fee',     '劳务费',   'formula', NULL,                     NULL,  1),
1395
+    ('plan_1',   'service_fee',   '服务费',   'rate',    NULL,                     0.08,  2),
1396
+    ('plan_1',   'insurance',     '保险',     'fixed',   NULL,                     NULL,  3),
1397
+    ('plan_1',   'tax_withhold',  '税费代征', 'formula', NULL,                     NULL,  4)
1398
+) AS f(plan_code, fee_code, fee_name, calc_type, calc_expression, default_rate, sort_order)
1392 1399
 ON p.plan_code = f.plan_code;
1393 1400
 
1394 1401
 -- -----------------------------------------------------------------------------

+ 1 - 1
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mcp/McpDraftController.java

@@ -89,7 +89,7 @@ public class McpDraftController {
89 89
 
90 90
     @PostMapping("/submit")
91 91
     @Operation(summary = "确认并提交草稿",
92
-            description = "用户选定方案后转化为正式订单,需先调用 calculate。",
92
+            description = "用户选定方案后转化为正式订单,并创建登记批次、立刻生成小程序码(fe_registration_batch.qr_code_url)。需先调用 calculate。",
93 93
             parameters = {
94 94
                     @Parameter(name = "X-Fe-Ai-Key", in = ParameterIn.HEADER)
95 95
             })

+ 4 - 2
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/mp/DraftController.java

@@ -46,7 +46,8 @@ public class DraftController {
46 46
 
47 47
     @GetMapping("/{id}")
48 48
     @Operation(summary = "查询用工草稿详情",
49
-            description = "返回草稿主表字段及 fe_draft_field 字段明细(含来源、可编辑性)。仅可查询本企业草稿。")
49
+            description = "返回草稿主表字段及 fe_draft_field 字段明细(含来源、可编辑性);"
50
+                    + "确认方案后额外返回 qr_code_url(来自 fe_registration_batch)。仅可查询本企业草稿。")
50 51
     public R<DraftDetailResponse> getDetail(@PathVariable("id") Long draftId) {
51 52
         LoginUser loginUser = requireEnterprise();
52 53
         return R.ok(draftService.getDetail(loginUser.getUserId(), draftId));
@@ -74,7 +75,8 @@ public class DraftController {
74 75
     @PostMapping("/{id}/confirm")
75 76
     @Operation(summary = "确认此方案",
76 77
             description = "草稿 pending 且 missingFields 为空后调用。传入 selected_scheme_code(如 SCHEME_A):"
77
-                    + "1)生成正式订单;2)将草稿 status 更新为 confirmed(含 confirmed_at/confirmed_by);"
78
+                    + "1)生成正式订单;2)草稿置为 confirmed;"
79
+                    + "3)创建 fe_registration_batch 并立刻生成小程序码写入 qr_code_url;"
78 80
                     + "若尚未测算会自动先测算。")
79 81
     public R<DraftConfirmResponse> confirm(@PathVariable("id") Long draftId,
80 82
                                            @Validated @RequestBody DraftConfirmRequest request) {

+ 41 - 0
huimv-employment/fe-api/src/main/java/com/huimv/employment/controller/publicapi/RegistrationQrController.java

@@ -0,0 +1,41 @@
1
+package com.huimv.employment.controller.publicapi;
2
+
3
+import com.huimv.employment.service.registration.RegistrationBatchService;
4
+import io.swagger.v3.oas.annotations.Operation;
5
+import io.swagger.v3.oas.annotations.tags.Tag;
6
+import org.springframework.http.HttpHeaders;
7
+import org.springframework.http.HttpStatus;
8
+import org.springframework.http.MediaType;
9
+import org.springframework.http.ResponseEntity;
10
+import org.springframework.web.bind.annotation.GetMapping;
11
+import org.springframework.web.bind.annotation.PathVariable;
12
+import org.springframework.web.bind.annotation.RequestMapping;
13
+import org.springframework.web.bind.annotation.RestController;
14
+
15
+/**
16
+ * 登记小程序码图片公开访问(路径写入 {@code fe_registration_batch.qr_code_url})。
17
+ */
18
+@RestController
19
+@RequestMapping("/api/v1/public/registration-qr")
20
+@Tag(name = "登记小程序码", description = "公开访问登记批次小程序码 PNG")
21
+public class RegistrationQrController {
22
+
23
+    private final RegistrationBatchService registrationBatchService;
24
+
25
+    public RegistrationQrController(RegistrationBatchService registrationBatchService) {
26
+        this.registrationBatchService = registrationBatchService;
27
+    }
28
+
29
+    @GetMapping("/{scene}.png")
30
+    @Operation(summary = "获取登记小程序码图片(scene=订单编号)")
31
+    public ResponseEntity<byte[]> getQrPng(@PathVariable("scene") String scene) {
32
+        byte[] png = registrationBatchService.readQrPng(scene);
33
+        if (png == null || png.length == 0) {
34
+            return ResponseEntity.status(HttpStatus.NOT_FOUND).build();
35
+        }
36
+        HttpHeaders headers = new HttpHeaders();
37
+        headers.setContentType(MediaType.IMAGE_PNG);
38
+        headers.setCacheControl("public, max-age=86400");
39
+        return new ResponseEntity<>(png, headers, HttpStatus.OK);
40
+    }
41
+}

+ 13 - 0
huimv-employment/fe-api/src/main/resources/application.yml

@@ -39,6 +39,19 @@ fe:
39 39
       app-id: wx01c326e8fff67376
40 40
       app-secret: 54715e3f42f5421e5771c58cbf2dedb3
41 41
       mock-openid: mock_wx_openid_dev
42
+  registration:
43
+    qr:
44
+      # 对应 POST /wxa/getwxacodeunlimit
45
+      page: pages/worker/job-offer
46
+      # 页面未发布到微信后台前务必 false,否则易报 41030/85079
47
+      check-path: false
48
+      env-version: release
49
+      # 微信失败时写占位图,避免确认方案失败回滚;真正上线可改为 false
50
+      fallback-mock-on-error: true
51
+      width: 430
52
+      default-valid-days: 7
53
+      storage-path: ${FE_QR_STORAGE_PATH:./data/registration-qr}
54
+      public-url-prefix: ${FE_QR_PUBLIC_URL_PREFIX:}
42 55
   sms:
43 56
     mock-enabled: true
44 57
     mock-code: "123456"

+ 211 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/entity/FeRegistrationBatch.java

@@ -0,0 +1,211 @@
1
+package com.huimv.employment.dao.entity;
2
+
3
+import com.baomidou.mybatisplus.annotation.IdType;
4
+import com.baomidou.mybatisplus.annotation.TableId;
5
+import com.baomidou.mybatisplus.annotation.TableLogic;
6
+import com.baomidou.mybatisplus.annotation.TableName;
7
+
8
+import java.time.LocalDateTime;
9
+
10
+/**
11
+ * 临时工扫码登记批次 {@code fe_registration_batch} 实体。
12
+ */
13
+@TableName("fe_registration_batch")
14
+public class FeRegistrationBatch {
15
+
16
+    @TableId(type = IdType.AUTO)
17
+    private Long id;
18
+
19
+    private String batchNo;
20
+
21
+    private Long orderId;
22
+
23
+    private Long enterpriseId;
24
+
25
+    private Integer expectedCount;
26
+
27
+    private Integer registeredCount;
28
+
29
+    private Integer pendingCount;
30
+
31
+    private Integer inProgressCount;
32
+
33
+    private Integer completedCount;
34
+
35
+    private Integer abnormalCount;
36
+
37
+    /** 小程序登记页 path(可带 token 查询参数) */
38
+    private String registrationUrl;
39
+
40
+    /** 小程序码图片可访问 URL */
41
+    private String qrCodeUrl;
42
+
43
+    /** 扫码 scene / 校验 token,最长 32(微信 scene 限制) */
44
+    private String qrToken;
45
+
46
+    private LocalDateTime validFrom;
47
+
48
+    private LocalDateTime validUntil;
49
+
50
+    /** active / expired / closed */
51
+    private String status;
52
+
53
+    private LocalDateTime createTime;
54
+
55
+    private LocalDateTime updateTime;
56
+
57
+    @TableLogic
58
+    private Integer delFlag;
59
+
60
+    public Long getId() {
61
+        return id;
62
+    }
63
+
64
+    public void setId(Long id) {
65
+        this.id = id;
66
+    }
67
+
68
+    public String getBatchNo() {
69
+        return batchNo;
70
+    }
71
+
72
+    public void setBatchNo(String batchNo) {
73
+        this.batchNo = batchNo;
74
+    }
75
+
76
+    public Long getOrderId() {
77
+        return orderId;
78
+    }
79
+
80
+    public void setOrderId(Long orderId) {
81
+        this.orderId = orderId;
82
+    }
83
+
84
+    public Long getEnterpriseId() {
85
+        return enterpriseId;
86
+    }
87
+
88
+    public void setEnterpriseId(Long enterpriseId) {
89
+        this.enterpriseId = enterpriseId;
90
+    }
91
+
92
+    public Integer getExpectedCount() {
93
+        return expectedCount;
94
+    }
95
+
96
+    public void setExpectedCount(Integer expectedCount) {
97
+        this.expectedCount = expectedCount;
98
+    }
99
+
100
+    public Integer getRegisteredCount() {
101
+        return registeredCount;
102
+    }
103
+
104
+    public void setRegisteredCount(Integer registeredCount) {
105
+        this.registeredCount = registeredCount;
106
+    }
107
+
108
+    public Integer getPendingCount() {
109
+        return pendingCount;
110
+    }
111
+
112
+    public void setPendingCount(Integer pendingCount) {
113
+        this.pendingCount = pendingCount;
114
+    }
115
+
116
+    public Integer getInProgressCount() {
117
+        return inProgressCount;
118
+    }
119
+
120
+    public void setInProgressCount(Integer inProgressCount) {
121
+        this.inProgressCount = inProgressCount;
122
+    }
123
+
124
+    public Integer getCompletedCount() {
125
+        return completedCount;
126
+    }
127
+
128
+    public void setCompletedCount(Integer completedCount) {
129
+        this.completedCount = completedCount;
130
+    }
131
+
132
+    public Integer getAbnormalCount() {
133
+        return abnormalCount;
134
+    }
135
+
136
+    public void setAbnormalCount(Integer abnormalCount) {
137
+        this.abnormalCount = abnormalCount;
138
+    }
139
+
140
+    public String getRegistrationUrl() {
141
+        return registrationUrl;
142
+    }
143
+
144
+    public void setRegistrationUrl(String registrationUrl) {
145
+        this.registrationUrl = registrationUrl;
146
+    }
147
+
148
+    public String getQrCodeUrl() {
149
+        return qrCodeUrl;
150
+    }
151
+
152
+    public void setQrCodeUrl(String qrCodeUrl) {
153
+        this.qrCodeUrl = qrCodeUrl;
154
+    }
155
+
156
+    public String getQrToken() {
157
+        return qrToken;
158
+    }
159
+
160
+    public void setQrToken(String qrToken) {
161
+        this.qrToken = qrToken;
162
+    }
163
+
164
+    public LocalDateTime getValidFrom() {
165
+        return validFrom;
166
+    }
167
+
168
+    public void setValidFrom(LocalDateTime validFrom) {
169
+        this.validFrom = validFrom;
170
+    }
171
+
172
+    public LocalDateTime getValidUntil() {
173
+        return validUntil;
174
+    }
175
+
176
+    public void setValidUntil(LocalDateTime validUntil) {
177
+        this.validUntil = validUntil;
178
+    }
179
+
180
+    public String getStatus() {
181
+        return status;
182
+    }
183
+
184
+    public void setStatus(String status) {
185
+        this.status = status;
186
+    }
187
+
188
+    public LocalDateTime getCreateTime() {
189
+        return createTime;
190
+    }
191
+
192
+    public void setCreateTime(LocalDateTime createTime) {
193
+        this.createTime = createTime;
194
+    }
195
+
196
+    public LocalDateTime getUpdateTime() {
197
+        return updateTime;
198
+    }
199
+
200
+    public void setUpdateTime(LocalDateTime updateTime) {
201
+        this.updateTime = updateTime;
202
+    }
203
+
204
+    public Integer getDelFlag() {
205
+        return delFlag;
206
+    }
207
+
208
+    public void setDelFlag(Integer delFlag) {
209
+        this.delFlag = delFlag;
210
+    }
211
+}

+ 9 - 0
huimv-employment/fe-dao/src/main/java/com/huimv/employment/dao/mapper/FeRegistrationBatchMapper.java

@@ -0,0 +1,9 @@
1
+package com.huimv.employment.dao.mapper;
2
+
3
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
4
+import com.huimv.employment.dao.entity.FeRegistrationBatch;
5
+import org.apache.ibatis.annotations.Mapper;
6
+
7
+@Mapper
8
+public interface FeRegistrationBatchMapper extends BaseMapper<FeRegistrationBatch> {
9
+}

+ 9 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/config/RegistrationQrConfiguration.java

@@ -0,0 +1,9 @@
1
+package com.huimv.employment.service.config;
2
+
3
+import org.springframework.boot.context.properties.EnableConfigurationProperties;
4
+import org.springframework.context.annotation.Configuration;
5
+
6
+@Configuration
7
+@EnableConfigurationProperties(RegistrationQrProperties.class)
8
+public class RegistrationQrConfiguration {
9
+}

+ 109 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/config/RegistrationQrProperties.java

@@ -0,0 +1,109 @@
1
+package com.huimv.employment.service.config;
2
+
3
+import org.springframework.boot.context.properties.ConfigurationProperties;
4
+
5
+/**
6
+ * 登记小程序码配置,绑定 {@code fe.registration.qr.*}。
7
+ */
8
+@ConfigurationProperties(prefix = "fe.registration.qr")
9
+public class RegistrationQrProperties {
10
+
11
+    /**
12
+     * getwxacodeunlimit.page(不要以 / 开头,不要带 query)。
13
+     * 对应 POST /wxa/getwxacodeunlimit 的 page。
14
+     */
15
+    private String page = "pages/worker/job-offer";
16
+
17
+    /** release / trial / develop */
18
+    private String envVersion = "release";
19
+
20
+    /**
21
+     * 是否校验 page 存在;对应 check_path。
22
+     * 正式发布 pages/worker/job-offer 后可设 true;未发布时 true 会导致微信 41030/85079。
23
+     */
24
+    private boolean checkPath = false;
25
+
26
+    /**
27
+     * 微信生成失败时是否落 Mock PNG,避免确认方案整单回滚。
28
+     * 生产建议 false,联调可 true。
29
+     */
30
+    private boolean fallbackMockOnError = true;
31
+
32
+    private int width = 430;
33
+
34
+    /** 草稿未配置 qr_valid_days 时的默认有效天数 */
35
+    private int defaultValidDays = 7;
36
+
37
+    /** 小程序码 PNG 本地存储目录 */
38
+    private String storagePath = "./data/registration-qr";
39
+
40
+    /**
41
+     * 对外访问前缀,写入 {@code qr_code_url}。
42
+     * 为空则使用相对路径 {@code /api/v1/public/registration-qr/{token}.png}。
43
+     */
44
+    private String publicUrlPrefix = "";
45
+
46
+    public String getPage() {
47
+        return page;
48
+    }
49
+
50
+    public void setPage(String page) {
51
+        this.page = page;
52
+    }
53
+
54
+    public String getEnvVersion() {
55
+        return envVersion;
56
+    }
57
+
58
+    public void setEnvVersion(String envVersion) {
59
+        this.envVersion = envVersion;
60
+    }
61
+
62
+    public boolean isCheckPath() {
63
+        return checkPath;
64
+    }
65
+
66
+    public void setCheckPath(boolean checkPath) {
67
+        this.checkPath = checkPath;
68
+    }
69
+
70
+    public boolean isFallbackMockOnError() {
71
+        return fallbackMockOnError;
72
+    }
73
+
74
+    public void setFallbackMockOnError(boolean fallbackMockOnError) {
75
+        this.fallbackMockOnError = fallbackMockOnError;
76
+    }
77
+
78
+    public int getWidth() {
79
+        return width;
80
+    }
81
+
82
+    public void setWidth(int width) {
83
+        this.width = width;
84
+    }
85
+
86
+    public int getDefaultValidDays() {
87
+        return defaultValidDays;
88
+    }
89
+
90
+    public void setDefaultValidDays(int defaultValidDays) {
91
+        this.defaultValidDays = defaultValidDays;
92
+    }
93
+
94
+    public String getStoragePath() {
95
+        return storagePath;
96
+    }
97
+
98
+    public void setStoragePath(String storagePath) {
99
+        this.storagePath = storagePath;
100
+    }
101
+
102
+    public String getPublicUrlPrefix() {
103
+        return publicUrlPrefix;
104
+    }
105
+
106
+    public void setPublicUrlPrefix(String publicUrlPrefix) {
107
+        this.publicUrlPrefix = publicUrlPrefix;
108
+    }
109
+}

+ 36 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/DraftService.java

@@ -11,10 +11,12 @@ import com.huimv.employment.dao.entity.FeDraftField;
11 11
 import com.huimv.employment.dao.entity.FeEmploymentDraft;
12 12
 import com.huimv.employment.dao.entity.FeEmploymentOrder;
13 13
 import com.huimv.employment.dao.entity.FeEnterprise;
14
+import com.huimv.employment.dao.entity.FeRegistrationBatch;
14 15
 import com.huimv.employment.dao.mapper.FeDraftCostSnapshotMapper;
15 16
 import com.huimv.employment.dao.mapper.FeDraftFieldMapper;
16 17
 import com.huimv.employment.dao.mapper.FeEmploymentDraftMapper;
17 18
 import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
19
+import com.huimv.employment.dao.mapper.FeRegistrationBatchMapper;
18 20
 import com.huimv.employment.service.draft.dto.DraftConfirmRequest;
19 21
 import com.huimv.employment.service.draft.dto.DraftConfirmResponse;
20 22
 import com.huimv.employment.service.draft.dto.DraftDetailResponse;
@@ -22,6 +24,7 @@ import com.huimv.employment.service.draft.dto.DraftFieldResponse;
22 24
 import com.huimv.employment.service.draft.dto.DraftUpdateRequest;
23 25
 import com.huimv.employment.service.draft.dto.McpDraftCalculateResponse;
24 26
 import com.huimv.employment.service.enterprise.EnterpriseService;
27
+import com.huimv.employment.service.registration.RegistrationBatchService;
25 28
 import org.springframework.dao.DuplicateKeyException;
26 29
 import org.springframework.stereotype.Service;
27 30
 import org.springframework.transaction.annotation.Transactional;
@@ -76,23 +79,29 @@ public class DraftService {
76 79
     private final FeDraftFieldMapper feDraftFieldMapper;
77 80
     private final FeDraftCostSnapshotMapper feDraftCostSnapshotMapper;
78 81
     private final FeEmploymentOrderMapper feEmploymentOrderMapper;
82
+    private final FeRegistrationBatchMapper feRegistrationBatchMapper;
79 83
     private final EnterpriseService enterpriseService;
80 84
     private final DraftCostCalculationEngine draftCostCalculationEngine;
85
+    private final RegistrationBatchService registrationBatchService;
81 86
     private final ObjectMapper objectMapper;
82 87
 
83 88
     public DraftService(FeEmploymentDraftMapper feEmploymentDraftMapper,
84 89
                         FeDraftFieldMapper feDraftFieldMapper,
85 90
                         FeDraftCostSnapshotMapper feDraftCostSnapshotMapper,
86 91
                         FeEmploymentOrderMapper feEmploymentOrderMapper,
92
+                        FeRegistrationBatchMapper feRegistrationBatchMapper,
87 93
                         EnterpriseService enterpriseService,
88 94
                         DraftCostCalculationEngine draftCostCalculationEngine,
95
+                        RegistrationBatchService registrationBatchService,
89 96
                         ObjectMapper objectMapper) {
90 97
         this.feEmploymentDraftMapper = feEmploymentDraftMapper;
91 98
         this.feDraftFieldMapper = feDraftFieldMapper;
92 99
         this.feDraftCostSnapshotMapper = feDraftCostSnapshotMapper;
93 100
         this.feEmploymentOrderMapper = feEmploymentOrderMapper;
101
+        this.feRegistrationBatchMapper = feRegistrationBatchMapper;
94 102
         this.enterpriseService = enterpriseService;
95 103
         this.draftCostCalculationEngine = draftCostCalculationEngine;
104
+        this.registrationBatchService = registrationBatchService;
96 105
         this.objectMapper = objectMapper;
97 106
     }
98 107
 
@@ -184,12 +193,19 @@ public class DraftService {
184 193
             throw new BizException(ErrorCode.DRAFT_NOT_EDITABLE);
185 194
         }
186 195
 
196
+        // 3) 创建登记批次并立刻写入小程序码(fe_registration_batch.qr_code_url)
197
+        FeRegistrationBatch batch = registrationBatchService.createForOrder(order, draft, now);
198
+
187 199
         DraftConfirmResponse response = new DraftConfirmResponse();
188 200
         response.setStatus(CONFIRM_SUCCESS);
189 201
         response.setDraftStatus(STATUS_CONFIRMED);
190 202
         response.setOrderId(order.getOrderNo());
191 203
         response.setDraftId(draft.getId());
192 204
         response.setSelectedSchemeCode(schemeCode);
205
+        response.setBatchId(batch.getBatchNo());
206
+        response.setRegistrationUrl(batch.getRegistrationUrl());
207
+        response.setQrCodeUrl(batch.getQrCodeUrl());
208
+        response.setQrToken(batch.getQrToken());
193 209
         return response;
194 210
     }
195 211
 
@@ -511,12 +527,32 @@ public class DraftService {
511 527
         response.setEstimatedTotal(draft.getEstimatedTotal());
512 528
         response.setEstimatedPerCapita(draft.getEstimatedPerCapita());
513 529
         response.setSelectedPlanId(draft.getSelectedPlanId());
530
+        response.setQrCodeUrl(findQrCodeUrlByDraftId(draft.getId()));
514 531
         response.setFields(fields.stream().map(this::toFieldResponse).collect(Collectors.toList()));
515 532
         response.setCreateTime(draft.getCreateTime());
516 533
         response.setUpdateTime(draft.getUpdateTime());
517 534
         return response;
518 535
     }
519 536
 
537
+    /** 草稿确认后关联订单 → 登记批次上的小程序码;未确认则为 null。 */
538
+    private String findQrCodeUrlByDraftId(Long draftId) {
539
+        if (draftId == null) {
540
+            return null;
541
+        }
542
+        FeEmploymentOrder order = feEmploymentOrderMapper.selectOne(new LambdaQueryWrapper<FeEmploymentOrder>()
543
+                .eq(FeEmploymentOrder::getDraftId, draftId)
544
+                .orderByDesc(FeEmploymentOrder::getId)
545
+                .last("LIMIT 1"));
546
+        if (order == null) {
547
+            return null;
548
+        }
549
+        FeRegistrationBatch batch = feRegistrationBatchMapper.selectOne(new LambdaQueryWrapper<FeRegistrationBatch>()
550
+                .eq(FeRegistrationBatch::getOrderId, order.getId())
551
+                .orderByDesc(FeRegistrationBatch::getId)
552
+                .last("LIMIT 1"));
553
+        return batch != null ? batch.getQrCodeUrl() : null;
554
+    }
555
+
520 556
     private DraftFieldResponse toFieldResponse(FeDraftField entity) {
521 557
         DraftFieldResponse response = new DraftFieldResponse();
522 558
         response.setFieldKey(entity.getFieldKey());

+ 10 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/McpDraftService.java

@@ -10,11 +10,13 @@ import com.huimv.employment.dao.entity.FeDraftField;
10 10
 import com.huimv.employment.dao.entity.FeEmploymentDraft;
11 11
 import com.huimv.employment.dao.entity.FeEmploymentOrder;
12 12
 import com.huimv.employment.dao.entity.FeEnterprise;
13
+import com.huimv.employment.dao.entity.FeRegistrationBatch;
13 14
 import com.huimv.employment.dao.mapper.FeDraftCostSnapshotMapper;
14 15
 import com.huimv.employment.dao.mapper.FeDraftFieldMapper;
15 16
 import com.huimv.employment.dao.mapper.FeEmploymentDraftMapper;
16 17
 import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
17 18
 import com.huimv.employment.service.conversation.ConversationService;
19
+import com.huimv.employment.service.registration.RegistrationBatchService;
18 20
 import com.huimv.employment.service.draft.dto.McpDraftCalculateResponse;
19 21
 import com.huimv.employment.service.draft.dto.McpDraftGenerateRequest;
20 22
 import com.huimv.employment.service.draft.dto.McpDraftGenerateResponse;
@@ -81,6 +83,7 @@ public class McpDraftService {
81 83
     private final ConversationService conversationService;
82 84
     private final EnterpriseService enterpriseService;
83 85
     private final DraftCostCalculationEngine draftCostCalculationEngine;
86
+    private final RegistrationBatchService registrationBatchService;
84 87
     private final ObjectMapper objectMapper;
85 88
 
86 89
     public McpDraftService(FeEmploymentDraftMapper feEmploymentDraftMapper,
@@ -90,6 +93,7 @@ public class McpDraftService {
90 93
                            ConversationService conversationService,
91 94
                            EnterpriseService enterpriseService,
92 95
                            DraftCostCalculationEngine draftCostCalculationEngine,
96
+                           RegistrationBatchService registrationBatchService,
93 97
                            ObjectMapper objectMapper) {
94 98
         this.feEmploymentDraftMapper = feEmploymentDraftMapper;
95 99
         this.feDraftFieldMapper = feDraftFieldMapper;
@@ -98,6 +102,7 @@ public class McpDraftService {
98 102
         this.conversationService = conversationService;
99 103
         this.enterpriseService = enterpriseService;
100 104
         this.draftCostCalculationEngine = draftCostCalculationEngine;
105
+        this.registrationBatchService = registrationBatchService;
101 106
         this.objectMapper = objectMapper;
102 107
     }
103 108
 
@@ -202,9 +207,14 @@ public class McpDraftService {
202 207
         draftUpdate.setUpdateTime(now);
203 208
         feEmploymentDraftMapper.updateById(draftUpdate);
204 209
 
210
+        FeRegistrationBatch batch = registrationBatchService.createForOrder(order, draft, now);
211
+
205 212
         McpDraftSubmitResponse response = new McpDraftSubmitResponse();
206 213
         response.setStatus(SUBMIT_SUCCESS);
207 214
         response.setOrderId(order.getOrderNo());
215
+        response.setBatchId(batch.getBatchNo());
216
+        response.setRegistrationUrl(batch.getRegistrationUrl());
217
+        response.setQrCodeUrl(batch.getQrCodeUrl());
208 218
         return response;
209 219
     }
210 220
 

+ 48 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftConfirmResponse.java

@@ -25,6 +25,22 @@ public class DraftConfirmResponse {
25 25
     @Schema(description = "确认的方案编码", example = "SCHEME_A")
26 26
     private String selectedSchemeCode;
27 27
 
28
+    @JsonProperty("batch_id")
29
+    @Schema(description = "登记批次业务编号", example = "bat_1")
30
+    private String batchId;
31
+
32
+    @JsonProperty("registration_url")
33
+    @Schema(description = "临时工登记页 path", example = "pages/worker/register?token=abc")
34
+    private String registrationUrl;
35
+
36
+    @JsonProperty("qr_code_url")
37
+    @Schema(description = "小程序码图片 URL(fe_registration_batch.qr_code_url)")
38
+    private String qrCodeUrl;
39
+
40
+    @JsonProperty("qr_token")
41
+    @Schema(description = "扫码登记 token(写入小程序码 scene)")
42
+    private String qrToken;
43
+
28 44
     public String getStatus() {
29 45
         return status;
30 46
     }
@@ -64,4 +80,36 @@ public class DraftConfirmResponse {
64 80
     public void setSelectedSchemeCode(String selectedSchemeCode) {
65 81
         this.selectedSchemeCode = selectedSchemeCode;
66 82
     }
83
+
84
+    public String getBatchId() {
85
+        return batchId;
86
+    }
87
+
88
+    public void setBatchId(String batchId) {
89
+        this.batchId = batchId;
90
+    }
91
+
92
+    public String getRegistrationUrl() {
93
+        return registrationUrl;
94
+    }
95
+
96
+    public void setRegistrationUrl(String registrationUrl) {
97
+        this.registrationUrl = registrationUrl;
98
+    }
99
+
100
+    public String getQrCodeUrl() {
101
+        return qrCodeUrl;
102
+    }
103
+
104
+    public void setQrCodeUrl(String qrCodeUrl) {
105
+        this.qrCodeUrl = qrCodeUrl;
106
+    }
107
+
108
+    public String getQrToken() {
109
+        return qrToken;
110
+    }
111
+
112
+    public void setQrToken(String qrToken) {
113
+        this.qrToken = qrToken;
114
+    }
67 115
 }

+ 15 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/DraftDetailResponse.java

@@ -1,5 +1,7 @@
1 1
 package com.huimv.employment.service.draft.dto;
2 2
 
3
+import com.fasterxml.jackson.annotation.JsonInclude;
4
+import com.fasterxml.jackson.annotation.JsonProperty;
3 5
 import io.swagger.v3.oas.annotations.media.Schema;
4 6
 
5 7
 import java.math.BigDecimal;
@@ -97,6 +99,11 @@ public class DraftDetailResponse {
97 99
     @Schema(description = "已选方案 ID")
98 100
     private Long selectedPlanId;
99 101
 
102
+    @JsonProperty("qr_code_url")
103
+    @JsonInclude(JsonInclude.Include.ALWAYS)
104
+    @Schema(description = "登记小程序码图片 URL(确认方案后由 fe_registration_batch 带回;未确认时为 null)")
105
+    private String qrCodeUrl;
106
+
100 107
     @Schema(description = "字段级明细(来源、可编辑性)")
101 108
     private List<DraftFieldResponse> fields;
102 109
 
@@ -330,6 +337,14 @@ public class DraftDetailResponse {
330 337
         this.selectedPlanId = selectedPlanId;
331 338
     }
332 339
 
340
+    public String getQrCodeUrl() {
341
+        return qrCodeUrl;
342
+    }
343
+
344
+    public void setQrCodeUrl(String qrCodeUrl) {
345
+        this.qrCodeUrl = qrCodeUrl;
346
+    }
347
+
333 348
     public List<DraftFieldResponse> getFields() {
334 349
         return fields;
335 350
     }

+ 36 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/draft/dto/McpDraftSubmitResponse.java

@@ -13,6 +13,18 @@ public class McpDraftSubmitResponse {
13 13
     @Schema(description = "生成的正式订单 ID", example = "ord_98765")
14 14
     private String orderId;
15 15
 
16
+    @JsonProperty("batch_id")
17
+    @Schema(description = "登记批次业务编号", example = "bat_1")
18
+    private String batchId;
19
+
20
+    @JsonProperty("registration_url")
21
+    @Schema(description = "临时工登记页 path")
22
+    private String registrationUrl;
23
+
24
+    @JsonProperty("qr_code_url")
25
+    @Schema(description = "小程序码图片 URL")
26
+    private String qrCodeUrl;
27
+
16 28
     public String getStatus() {
17 29
         return status;
18 30
     }
@@ -28,4 +40,28 @@ public class McpDraftSubmitResponse {
28 40
     public void setOrderId(String orderId) {
29 41
         this.orderId = orderId;
30 42
     }
43
+
44
+    public String getBatchId() {
45
+        return batchId;
46
+    }
47
+
48
+    public void setBatchId(String batchId) {
49
+        this.batchId = batchId;
50
+    }
51
+
52
+    public String getRegistrationUrl() {
53
+        return registrationUrl;
54
+    }
55
+
56
+    public void setRegistrationUrl(String registrationUrl) {
57
+        this.registrationUrl = registrationUrl;
58
+    }
59
+
60
+    public String getQrCodeUrl() {
61
+        return qrCodeUrl;
62
+    }
63
+
64
+    public void setQrCodeUrl(String qrCodeUrl) {
65
+        this.qrCodeUrl = qrCodeUrl;
66
+    }
31 67
 }

+ 199 - 0
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/registration/RegistrationBatchService.java

@@ -0,0 +1,199 @@
1
+package com.huimv.employment.service.registration;
2
+
3
+import com.huimv.employment.common.exception.BizException;
4
+import com.huimv.employment.common.exception.ErrorCode;
5
+import com.huimv.employment.dao.entity.FeEmploymentDraft;
6
+import com.huimv.employment.dao.entity.FeEmploymentOrder;
7
+import com.huimv.employment.dao.entity.FeRegistrationBatch;
8
+import com.huimv.employment.dao.mapper.FeEmploymentOrderMapper;
9
+import com.huimv.employment.dao.mapper.FeRegistrationBatchMapper;
10
+import com.huimv.employment.service.config.RegistrationQrProperties;
11
+import com.huimv.employment.service.wechat.WeChatMiniAppService;
12
+import org.slf4j.Logger;
13
+import org.slf4j.LoggerFactory;
14
+import org.springframework.dao.DuplicateKeyException;
15
+import org.springframework.stereotype.Service;
16
+import org.springframework.util.StringUtils;
17
+
18
+import java.io.IOException;
19
+import java.nio.file.Files;
20
+import java.nio.file.Path;
21
+import java.nio.file.Paths;
22
+import java.time.LocalDateTime;
23
+import java.util.regex.Pattern;
24
+
25
+/**
26
+ * 方案确认后创建登记批次,并立刻生成小程序码写入 {@code fe_registration_batch.qr_code_url}。
27
+ * <p>小程序码 scene = 订单编号,page = pages/worker/job-offer(见微信 getwxacodeunlimit)。</p>
28
+ */
29
+@Service
30
+public class RegistrationBatchService {
31
+
32
+    private static final Logger log = LoggerFactory.getLogger(RegistrationBatchService.class);
33
+
34
+    private static final String STATUS_ACTIVE = "active";
35
+    private static final String STEP_REGISTRATION = "registration";
36
+    private static final String PUBLIC_PATH_TEMPLATE = "/api/v1/public/registration-qr/%s.png";
37
+    /** 订单编号 / 文件名安全字符 */
38
+    private static final Pattern SAFE_SCENE = Pattern.compile("^[A-Za-z0-9_-]{1,32}$");
39
+
40
+    private final FeRegistrationBatchMapper feRegistrationBatchMapper;
41
+    private final FeEmploymentOrderMapper feEmploymentOrderMapper;
42
+    private final WeChatMiniAppService weChatMiniAppService;
43
+    private final RegistrationQrProperties qrProperties;
44
+
45
+    public RegistrationBatchService(FeRegistrationBatchMapper feRegistrationBatchMapper,
46
+                                    FeEmploymentOrderMapper feEmploymentOrderMapper,
47
+                                    WeChatMiniAppService weChatMiniAppService,
48
+                                    RegistrationQrProperties qrProperties) {
49
+        this.feRegistrationBatchMapper = feRegistrationBatchMapper;
50
+        this.feEmploymentOrderMapper = feEmploymentOrderMapper;
51
+        this.weChatMiniAppService = weChatMiniAppService;
52
+        this.qrProperties = qrProperties;
53
+    }
54
+
55
+    /**
56
+     * 一单一主批次:落批次 + 生成小程序码 + 推进订单步骤到 registration。
57
+     */
58
+    public FeRegistrationBatch createForOrder(FeEmploymentOrder order, FeEmploymentDraft draft, LocalDateTime now) {
59
+        if (order == null || order.getId() == null) {
60
+            throw new BizException(ErrorCode.BAD_REQUEST, "订单不存在,无法创建登记批次");
61
+        }
62
+        if (!StringUtils.hasText(order.getOrderNo()) || !SAFE_SCENE.matcher(order.getOrderNo().trim()).matches()) {
63
+            throw new BizException(ErrorCode.BAD_REQUEST, "订单编号无效,无法作为小程序码 scene");
64
+        }
65
+        // getwxacodeunlimit.scene = 订单编号(≤32)
66
+        String scene = order.getOrderNo().trim();
67
+        int validDays = resolveValidDays(draft);
68
+        String page = normalizePage(qrProperties.getPage());
69
+        String registrationUrl = page + "?scene=" + scene;
70
+
71
+        FeRegistrationBatch batch = new FeRegistrationBatch();
72
+        batch.setBatchNo("tmp_" + scene);
73
+        batch.setOrderId(order.getId());
74
+        batch.setEnterpriseId(order.getEnterpriseId());
75
+        int expected = order.getWorkerCount() != null && order.getWorkerCount() > 0
76
+                ? order.getWorkerCount() : 0;
77
+        batch.setExpectedCount(expected);
78
+        batch.setRegisteredCount(0);
79
+        batch.setPendingCount(expected);
80
+        batch.setInProgressCount(0);
81
+        batch.setCompletedCount(0);
82
+        batch.setAbnormalCount(0);
83
+        batch.setRegistrationUrl(registrationUrl);
84
+        batch.setQrToken(scene);
85
+        batch.setValidFrom(now);
86
+        batch.setValidUntil(now.plusDays(validDays));
87
+        batch.setStatus(STATUS_ACTIVE);
88
+        batch.setCreateTime(now);
89
+        batch.setUpdateTime(now);
90
+        batch.setDelFlag(0);
91
+
92
+        try {
93
+            feRegistrationBatchMapper.insert(batch);
94
+        } catch (DuplicateKeyException ex) {
95
+            throw new BizException(ErrorCode.BAD_REQUEST, "登记批次编号冲突,请重试");
96
+        }
97
+        batch.setBatchNo(String.format("bat_%d", batch.getId()));
98
+        feRegistrationBatchMapper.updateById(batch);
99
+
100
+        attachQrCode(batch, scene, now);
101
+        advanceOrderToRegistration(order.getId(), now);
102
+        return feRegistrationBatchMapper.selectById(batch.getId());
103
+    }
104
+
105
+    /** 按订单编号(scene)读取本地小程序码 PNG;不存在返回 null。 */
106
+    public byte[] readQrPng(String scene) {
107
+        if (!StringUtils.hasText(scene) || !SAFE_SCENE.matcher(scene.trim()).matches()) {
108
+            return null;
109
+        }
110
+        Path file = resolveQrFile(scene.trim());
111
+        if (!Files.isRegularFile(file)) {
112
+            return null;
113
+        }
114
+        try {
115
+            return Files.readAllBytes(file);
116
+        } catch (IOException ex) {
117
+            log.warn("读取小程序码失败 scene={}", scene, ex);
118
+            return null;
119
+        }
120
+    }
121
+
122
+    private void attachQrCode(FeRegistrationBatch batch, String scene, LocalDateTime now) {
123
+        try {
124
+            byte[] png;
125
+            try {
126
+                png = weChatMiniAppService.createRegistrationWxaCode(scene);
127
+            } catch (BizException ex) {
128
+                if (!qrProperties.isFallbackMockOnError()) {
129
+                    throw ex;
130
+                }
131
+                log.error("订单 orderId={} 微信小程序码失败,降级 Mock PNG: {}",
132
+                        batch.getOrderId(), ex.getMessage());
133
+                png = weChatMiniAppService.createRegistrationWxaCodeForFallback(scene);
134
+            }
135
+            Path file = resolveQrFile(scene);
136
+            Files.createDirectories(file.getParent());
137
+            Files.write(file, png);
138
+
139
+            String qrCodeUrl = buildPublicQrUrl(scene);
140
+            FeRegistrationBatch update = new FeRegistrationBatch();
141
+            update.setId(batch.getId());
142
+            update.setQrCodeUrl(qrCodeUrl);
143
+            update.setUpdateTime(now);
144
+            feRegistrationBatchMapper.updateById(update);
145
+            batch.setQrCodeUrl(qrCodeUrl);
146
+        } catch (BizException ex) {
147
+            log.error("订单 orderId={} 生成登记小程序码失败: {}", batch.getOrderId(), ex.getMessage());
148
+            throw ex;
149
+        } catch (Exception ex) {
150
+            log.error("订单 orderId={} 写入登记小程序码失败", batch.getOrderId(), ex);
151
+            throw new BizException(ErrorCode.INTERNAL_ERROR, "写入登记小程序码失败");
152
+        }
153
+    }
154
+
155
+    private void advanceOrderToRegistration(Long orderId, LocalDateTime now) {
156
+        FeEmploymentOrder update = new FeEmploymentOrder();
157
+        update.setId(orderId);
158
+        update.setCurrentStepCode(STEP_REGISTRATION);
159
+        update.setUpdateTime(now);
160
+        feEmploymentOrderMapper.updateById(update);
161
+    }
162
+
163
+    private int resolveValidDays(FeEmploymentDraft draft) {
164
+        if (draft != null && draft.getQrValidDays() != null && draft.getQrValidDays() > 0) {
165
+            return draft.getQrValidDays();
166
+        }
167
+        return Math.max(1, qrProperties.getDefaultValidDays());
168
+    }
169
+
170
+    private static String normalizePage(String page) {
171
+        if (!StringUtils.hasText(page)) {
172
+            return "pages/worker/job-offer";
173
+        }
174
+        String p = page.trim();
175
+        if (p.startsWith("/")) {
176
+            p = p.substring(1);
177
+        }
178
+        int q = p.indexOf('?');
179
+        if (q >= 0) {
180
+            p = p.substring(0, q);
181
+        }
182
+        return p;
183
+    }
184
+
185
+    private Path resolveQrFile(String scene) {
186
+        return Paths.get(qrProperties.getStoragePath()).toAbsolutePath().normalize()
187
+                .resolve(scene + ".png");
188
+    }
189
+
190
+    private String buildPublicQrUrl(String scene) {
191
+        String relative = String.format(PUBLIC_PATH_TEMPLATE, scene);
192
+        String prefix = qrProperties.getPublicUrlPrefix();
193
+        if (!StringUtils.hasText(prefix)) {
194
+            return relative;
195
+        }
196
+        String base = prefix.endsWith("/") ? prefix.substring(0, prefix.length() - 1) : prefix.trim();
197
+        return base + relative;
198
+    }
199
+}

+ 148 - 1
huimv-employment/fe-service/src/main/java/com/huimv/employment/service/wechat/WeChatMiniAppService.java

@@ -2,26 +2,49 @@ package com.huimv.employment.service.wechat;
2 2
 
3 3
 import cn.binarywang.wx.miniapp.api.WxMaService;
4 4
 import cn.binarywang.wx.miniapp.bean.WxMaJscode2SessionResult;
5
+import cn.binarywang.wx.miniapp.bean.WxaCodeUnlimit;
6
+import cn.binarywang.wx.miniapp.executor.QrcodeBytesRequestExecutor;
5 7
 import com.huimv.employment.common.exception.BizException;
6 8
 import com.huimv.employment.common.exception.ErrorCode;
9
+import com.huimv.employment.service.config.RegistrationQrProperties;
7 10
 import com.huimv.employment.service.config.WeChatMiniAppProperties;
8 11
 import me.chanjar.weixin.common.error.WxErrorException;
12
+import org.slf4j.Logger;
13
+import org.slf4j.LoggerFactory;
9 14
 import org.springframework.beans.factory.ObjectProvider;
10 15
 import org.springframework.stereotype.Service;
11 16
 import org.springframework.util.StringUtils;
12 17
 
18
+import java.awt.Color;
19
+import java.awt.Font;
20
+import java.awt.Graphics2D;
21
+import java.awt.image.BufferedImage;
22
+import java.io.ByteArrayOutputStream;
23
+import java.util.regex.Pattern;
24
+import javax.imageio.ImageIO;
25
+
26
+import static cn.binarywang.wx.miniapp.constant.WxMaApiUrlConstants.Qrcode.GET_WXACODE_UNLIMIT_URL;
27
+
13 28
 /**
14
- * 微信小程序 code2session。
29
+ * 微信小程序:登录会话与登记小程序码
15 30
  */
16 31
 @Service
17 32
 public class WeChatMiniAppService {
18 33
 
34
+    private static final Logger log = LoggerFactory.getLogger(WeChatMiniAppService.class);
35
+
36
+    /** 微信 scene 允许字符(不含中文空格等) */
37
+    private static final Pattern SCENE_PATTERN = Pattern.compile("^[0-9A-Za-z!#$&'()*+,/:;=?@\\-._~]{1,32}$");
38
+
19 39
     private final WeChatMiniAppProperties properties;
40
+    private final RegistrationQrProperties registrationQrProperties;
20 41
     private final ObjectProvider<WxMaService> wxMaServiceProvider;
21 42
 
22 43
     public WeChatMiniAppService(WeChatMiniAppProperties properties,
44
+                                RegistrationQrProperties registrationQrProperties,
23 45
                                 ObjectProvider<WxMaService> wxMaServiceProvider) {
24 46
         this.properties = properties;
47
+        this.registrationQrProperties = registrationQrProperties;
25 48
         this.wxMaServiceProvider = wxMaServiceProvider;
26 49
     }
27 50
 
@@ -44,6 +67,130 @@ public class WeChatMiniAppService {
44 67
         }
45 68
     }
46 69
 
70
+    /**
71
+     * 生成不限制的小程序码。
72
+     * <p>
73
+     * access_token 由 WxMaService 按
74
+     * {@code GET /cgi-bin/token?grant_type=client_credential&appid&secret} 自动获取;
75
+     * 码体对应 {@code POST /wxa/getwxacodeunlimit},scene 传订单编号(最长 32)。
76
+     * </p>
77
+     */
78
+    public byte[] createRegistrationWxaCode(String scene) {
79
+        if (!StringUtils.hasText(scene)) {
80
+            throw new BizException(ErrorCode.BAD_REQUEST, "小程序码 scene(订单编号)不能为空");
81
+        }
82
+        String normalizedScene = scene.trim();
83
+        if (!SCENE_PATTERN.matcher(normalizedScene).matches()) {
84
+            throw new BizException(ErrorCode.BAD_REQUEST,
85
+                    "小程序码 scene 非法(仅支持 1-32 位数字/字母及 !#$&'()*+,/:;=?@-._~):" + scene);
86
+        }
87
+        if (properties.isMockEnabled()) {
88
+            return createMockQrPng(normalizedScene, registrationQrProperties.getWidth());
89
+        }
90
+        WxMaService wxMaService = wxMaServiceProvider.getIfAvailable();
91
+        if (wxMaService == null) {
92
+            throw new BizException(ErrorCode.INTERNAL_ERROR, "微信小程序未配置");
93
+        }
94
+        String page = registrationQrProperties.getPage();
95
+        if (page != null && page.startsWith("/")) {
96
+            page = page.substring(1);
97
+        }
98
+        boolean checkPath = registrationQrProperties.isCheckPath();
99
+        try {
100
+            return invokeGetWxaCodeUnlimit(wxMaService, normalizedScene, page, checkPath);
101
+        } catch (WxErrorException ex) {
102
+            // 页面未发布到对应 env 时常见 41030 / 85079;自动降级为 check_path=false 再试一次
103
+            if (checkPath && isPagePathError(ex)) {
104
+                log.warn("check_path=true 生成失败,降级 check_path=false 重试 scene={}, page={}, err={}",
105
+                        normalizedScene, page, formatWxError(ex));
106
+                try {
107
+                    return invokeGetWxaCodeUnlimit(wxMaService, normalizedScene, page, false);
108
+                } catch (WxErrorException retryEx) {
109
+                    throw toQrBizException(normalizedScene, page, retryEx);
110
+                }
111
+            }
112
+            throw toQrBizException(normalizedScene, page, ex);
113
+        }
114
+    }
115
+
116
+    /**
117
+     * 组装与微信文档一致的请求体,禁止把 line_color 置 null(否则报「请求的参数不正确」)。
118
+     */
119
+    private byte[] invokeGetWxaCodeUnlimit(WxMaService wxMaService,
120
+                                           String scene,
121
+                                           String page,
122
+                                           boolean checkPath) throws WxErrorException {
123
+        WxaCodeUnlimit req = new WxaCodeUnlimit();
124
+        req.setScene(scene);
125
+        req.setPage(page);
126
+        req.setCheckPath(checkPath);
127
+        String env = registrationQrProperties.getEnvVersion();
128
+        req.setEnvVersion(StringUtils.hasText(env) ? env.trim() : "release");
129
+        int width = registrationQrProperties.getWidth();
130
+        req.setWidth(width > 0 ? width : 430);
131
+        // 保持 bean 默认 auto_color=true / line_color={"r":"0","g":"0","b":"0"},勿 setLineColor(null)
132
+        log.info("调用 getwxacodeunlimit body={}", req.toJson());
133
+        return wxMaService.execute(
134
+                QrcodeBytesRequestExecutor.create(wxMaService.getRequestHttp()),
135
+                GET_WXACODE_UNLIMIT_URL,
136
+                req);
137
+    }
138
+
139
+    private static boolean isPagePathError(WxErrorException ex) {
140
+        if (ex.getError() == null) {
141
+            return false;
142
+        }
143
+        int code = ex.getError().getErrorCode();
144
+        // 41030 invalid page;85079 page not found / not published for env
145
+        return code == 41030 || code == 85079 || code == 40165 || code == 40212;
146
+    }
147
+
148
+    private BizException toQrBizException(String scene, String page, WxErrorException ex) {
149
+        String detail = formatWxError(ex);
150
+        log.warn("生成登记小程序码失败 scene={}, page={}, env={}, err={}",
151
+                scene, page, registrationQrProperties.getEnvVersion(), detail);
152
+        return new BizException(ErrorCode.INTERNAL_ERROR,
153
+                "生成登记小程序码失败:" + detail
154
+                        + "(请确认 access_token 有效,且 pages/worker/job-offer 已上传到 "
155
+                        + registrationQrProperties.getEnvVersion()
156
+                        + ";未发布时可将 fe.registration.qr.check-path 设为 false)");
157
+    }
158
+
159
+    private static String formatWxError(WxErrorException ex) {
160
+        if (ex.getError() == null) {
161
+            return ex.getMessage();
162
+        }
163
+        return "errcode=" + ex.getError().getErrorCode() + ", " + ex.getError().getErrorMsg();
164
+    }
165
+
166
+    /** 微信失败时的降级 PNG(不依赖 mock-enabled / 网络)。 */
167
+    public byte[] createRegistrationWxaCodeForFallback(String scene) {
168
+        return createMockQrPng(scene, registrationQrProperties.getWidth());
169
+    }
170
+
171
+    private static byte[] createMockQrPng(String scene, int width) {
172
+        int size = width > 0 ? width : 430;
173
+        BufferedImage image = new BufferedImage(size, size, BufferedImage.TYPE_INT_RGB);
174
+        Graphics2D g = image.createGraphics();
175
+        try {
176
+            g.setColor(Color.WHITE);
177
+            g.fillRect(0, 0, size, size);
178
+            g.setColor(Color.BLACK);
179
+            g.drawRect(8, 8, size - 16, size - 16);
180
+            g.setFont(new Font("SansSerif", Font.BOLD, 18));
181
+            g.drawString("MOCK WXA CODE", 24, size / 2 - 10);
182
+            g.setFont(new Font("SansSerif", Font.PLAIN, 14));
183
+            g.drawString(scene, 24, size / 2 + 20);
184
+            ByteArrayOutputStream out = new ByteArrayOutputStream();
185
+            ImageIO.write(image, "png", out);
186
+            return out.toByteArray();
187
+        } catch (Exception ex) {
188
+            throw new BizException(ErrorCode.INTERNAL_ERROR, "生成 Mock 小程序码失败");
189
+        } finally {
190
+            g.dispose();
191
+        }
192
+    }
193
+
47 194
     public static class WxSession {
48 195
         private final String openid;
49 196
         private final String unionid;